From 46fb194a02843df221f050fb24ad14e235c89ee1 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 28 Mar 2026 17:16:38 -0500 Subject: [PATCH] 's' --- config/hardware.env.example | 14 + docs/install-profiles.md | 7 + douyin_youtube_ffplay.py | 46 ++-- ecosystem.config.cjs | 30 ++- main.py | 152 +++++------ scripts/hardware_probe.py | 16 +- src/async_thread_loop.py | 18 ++ src/hub_routes.py | 26 +- src/web_process_backend.py | 57 ++++- src/youtube_ini_sync.py | 24 ++ tiktok.py | 152 +++++------ tools/deploy_live_paramiko.py | 11 +- .../live-product/LiveAndroidWorkstation.tsx | 157 ++++++++---- .../live-product/LiveControlApp.tsx | 33 ++- .../live-product/LiveProcessLiveLogCard.tsx | 241 ++++++++++++++++++ .../live-product/LiveTiktokHdmiView.tsx | 48 +++- .../live-product/LiveYoutubeUnmannedView.tsx | 82 +++++- web-console/lib/youtubeConfigUtils.ts | 18 ++ web.py | 6 +- youtube.py | 155 +++++------ 20 files changed, 962 insertions(+), 331 deletions(-) create mode 100644 config/hardware.env.example create mode 100644 src/async_thread_loop.py create mode 100644 src/youtube_ini_sync.py create mode 100644 web-console/components/live-product/LiveProcessLiveLogCard.tsx diff --git a/config/hardware.env.example b/config/hardware.env.example new file mode 100644 index 0000000..e38d73e --- /dev/null +++ b/config/hardware.env.example @@ -0,0 +1,14 @@ +# 复制为 config/hardware.env 或安装后由 scripts/hardware_probe.py --write 生成 +# 抖音→YouTube 推流(douyin_youtube_ffplay.py)在 Linux 上会读取本文件中的 LIVE_VIDEO_ENCODER。 +# +# —— ARM(RK3588 等)常见:h264_rkmpp(需 /dev/mpp_service 与 ffmpeg 含 rkmpp) +# —— ARM 通用 V4L2:h264_v4l2m2m +# —— x86_64 Intel/AMD 核显(VA-API):h264_vaapi(需 /dev/dri render 节点) +# —— x86_64 Intel Quick Sync:h264_qsv(需 ffmpeg 编译带 libmfx/oneVPL) +# —— NVIDIA:一般无需写此处,推流脚本会自行检测 nvidia-smi + h264_nvenc +# +# LIVE_VIDEO_ENCODER=h264_v4l2m2m +# LIVE_HW_DECODER=cpu +# LIVE_HDMI_PLAYER=mpv-drm +# LIVE_BROWSER_HWACCEL=auto +# LIVE_STABILITY_MODE=safe diff --git a/docs/install-profiles.md b/docs/install-profiles.md index 54cdf6d..30840b2 100644 --- a/docs/install-profiles.md +++ b/docs/install-profiles.md @@ -36,6 +36,13 @@ Use this when the device should only run the Douyin or YouTube business stack. - Builds the web console and enables `live-console.service` - Generates the hardware profile for adaptive ARM or X86 defaults +### PM2:单频道与多频道 Pro(Linux ARM / x86_64) + +- **单频道**:在项目根启用一个业务进程即可,例如取消注释 `ecosystem.config.cjs` 里的 `youtube` 或 `tiktok`,执行 `pm2 start ecosystem.config.cjs && pm2 save`。 +- **多频道 Pro**:每路直播需要**独立的** `config/youtube.ini` 与 `config/URL_config.ini`(或整份独立工作目录)。为第二路及以后各复制一份目录(或符号链接脚本外的配置),在 PM2 里为每个 app 设置不同的 `cwd`,`name` 与 Web 控制台 Pro 线路建议名一致(如 `youtube2__`)。 +- **硬件编码**:`sudo python3 scripts/hardware_probe.py --write` 会写入 `config/hardware.env` 与 `hardware-profile.json`;推流侧可选 `LIVE_VIDEO_ENCODER`(RKMPP / V4L2M2M / VA-API / QSV 等,见 `config/hardware.env.example`)。NVIDIA 一般由 `douyin_youtube_ffplay.py` 自动走 NVENC。 +- **音频链**:与 GitLab 分支 `ffmpegAudioRMFoode` 中 `douyin_youtube_ffplay.py` 的 FFmpeg 处理思路一致(`fast_audio`、arnndn、EQ 等);当前主线在 Linux 上还包含板载编码探测与推流稳定性增强,以前端无需对齐 Electron 为准。 + ### `install-all.sh` Use this when the node should be both the super hub and the livestream business node. diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index 4317531..71e2a9d 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -70,7 +70,6 @@ def start_douyin_youtube_ffplay( recording: set, recording_time_list: dict, running_list: list, - monitoring: list, clear_record_info, color_obj, ): @@ -125,18 +124,22 @@ def start_douyin_youtube_ffplay( STALE_OUTPUT_SECONDS = 40 RECONNECT_DELAY_MAX = 10 - END_KEYWORDS = [ + # 明确结束 / 平台拒绝:停止整条转推(与瞬时网络错误区分) + TERMINAL_KEYWORDS = [ + "live has ended", + "server returned 404", + "ingestion error", + "access denied", + "server returned 403", + "client disconnected", + ] + # 瞬时错误:结束当前 ffmpeg 进程后由外层循环退避重连 + TRANSIENT_KEYWORDS = [ "connection reset by peer", "failed to write trailer", "invalid data found", - "server returned 403", - "server returned 404", "server returned 500", - "ingestion error", - "access denied", "packet too short", - "client disconnected", - "live has ended", "no route to host", "connection timed out", "connection refused", @@ -364,13 +367,20 @@ def start_douyin_youtube_ffplay( print(f"[FFmpeg] {line}") line_lower = line.lower() - if any(kw in line_lower for kw in END_KEYWORDS): - print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流") + if any(kw in line_lower for kw in TERMINAL_KEYWORDS): + print("[ERROR] 判定直播结束或平台明确拒绝,停止推流") cleanup_proc(proc) raise StreamEnded() + if any(kw in line_lower for kw in TRANSIENT_KEYWORDS): + print(f"[WARN] 瞬时错误,将退避重试: {line[:160]}") + cleanup_proc(proc) + retry_delay = min(max(retry_delay, INITIAL_RETRY_DELAY) * 2, MAX_RETRY_DELAY) + break + if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower: last_frame_time = time.time() + retry_delay = INITIAL_RETRY_DELAY if time.time() - start_time > START_CHECK_AFTER: if time.time() - last_frame_time > NO_FRAME_TIMEOUT: @@ -406,13 +416,19 @@ def start_douyin_youtube_ffplay( cleanup_proc(proc) try: - recording.discard(record_name) recording_time_list.pop(record_name, None) - if real_url in running_list: - running_list.remove(real_url) - monitoring[0] = max(0, monitoring[0] - 1) + # 统一由 clear_record_info 维护 running_list / monitoring / url_comments clear_record_info(record_name, real_url) except Exception: pass - color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN) \ No newline at end of file + color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN) + + +if __name__ == "__main__": + # 兼容旧 PM2/脚本名:实际业务入口为 youtube.py(监控 URL_config + 调用本模块推流) + import runpy + from pathlib import Path + + _root = Path(__file__).resolve().parent + runpy.run_path(str(_root / "youtube.py"), run_name="__main__") \ No newline at end of file diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 39abeba..48da343 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,6 +1,11 @@ /** * PM2:在项目根执行 `pm2 start ecosystem.config.cjs` 后 `pm2 save` * web 使用 scripts/launch.py:若缺少 web-console/out 会自动 npm build 再起 uvicorn + * + * 单频道:通常只启一个 youtube.py(或 tiktok.py)+ web。 + * 多频道 Pro:每路需要独立 config/youtube.ini + URL_config.ini(或独立工作目录), + * 请为每路复制一份目录或使用符号链接,再为每个 PM2 app 设置不同的 cwd,进程名与控制台 Pro 线路一致(如 youtube2__xxx)。 + * 音频处理链与 GitLab 分支 ffmpegAudioRMFoode 中 douyin_youtube_ffplay 对齐;Linux ARM/x86 编码由 config/hardware.env 与硬件探测辅助。 */ const path = require("path"); const root = __dirname; @@ -18,8 +23,29 @@ module.exports = { PORT: process.env.PORT || "8001", }, }, - // { name: "youtube", cwd: root, script: py, args: "youtube.py" }, - // { name: "tiktok", cwd: root, script: py, args: "tiktok.py" }, + // —— 单频道直播(Linux ARM/x86 通用,二选一或按需启用)—— + // { + // name: "youtube", + // cwd: root, + // script: "youtube.py", + // interpreter: py, + // env: { PYTHONUNBUFFERED: "1" }, + // }, + // { + // name: "tiktok", + // cwd: root, + // script: "tiktok.py", + // interpreter: py, + // env: { PYTHONUNBUFFERED: "1" }, + // }, + // —— Pro 第二路示例:把 /opt/live/lane2 换成你独立配置目录(内含 config/youtube.ini、URL_config.ini)—— + // { + // name: "youtube2__lane2", + // cwd: "/opt/live/lane2", + // script: "youtube.py", + // interpreter: py, + // env: { PYTHONUNBUFFERED: "1" }, + // }, // Linux: { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` }, // Win: { name: "obs", cwd: root, script: "cmd.exe", args: "/c obs.bat" }, ], diff --git a/main.py b/main.py index 2417f86..f27ffbd 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,8 @@ from __future__ import annotations import asyncio import os + +from src.async_thread_loop import run_async_coro import sys import builtins import subprocess @@ -669,27 +671,27 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + json_data = run_async_coro(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( + json_data = run_async_coro(spider.get_douyin_app_stream_data( url=record_url, proxy_addr=proxy_address, cookies=dy_cookie)) - port_info = asyncio.run( + port_info = run_async_coro( 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( + json_data = run_async_coro(spider.get_tiktok_stream_data( url=record_url, proxy_addr=proxy_address, cookies=tiktok_cookie)) - port_info = asyncio.run( + port_info = run_async_coro( stream.get_tiktok_stream_url(json_data, record_quality, proxy_address)) else: logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台") @@ -697,23 +699,23 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("https://live.kuaishou.com/") > -1: platform = '快手直播' with semaphore: - json_data = asyncio.run(spider.get_kuaishou_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_huya_stream_url(json_data, record_quality)) else: - port_info = asyncio.run(spider.get_huya_app_stream_url( + port_info = run_async_coro(spider.get_huya_app_stream_url( url=record_url, proxy_addr=proxy_address, cookies=hy_cookie @@ -722,52 +724,52 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("https://www.douyu.com/") > -1: platform = '斗鱼直播' with semaphore: - json_data = asyncio.run(spider.get_douyu_info_data( + json_data = run_async_coro(spider.get_douyu_info_data( url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie)) - port_info = asyncio.run(stream.get_douyu_stream_url( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_bilibili_room_info( url=record_url, proxy_addr=proxy_address, cookies=bili_cookie)) - port_info = asyncio.run(stream.get_bilibili_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_sooplive_stream_data( url=record_url, proxy_addr=proxy_address, cookies=sooplive_cookie, username=sooplive_username, @@ -777,51 +779,51 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_netease_stream_data( url=record_url, cookies=netease_cookie)) - port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问WinkTV直播平台") @@ -829,7 +831,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: platform = 'FlexTV' with semaphore: if global_proxy or proxy_address: - json_data = asyncio.run(spider.get_flextv_stream_data( + json_data = run_async_coro(spider.get_flextv_stream_data( url=record_url, proxy_addr=proxy_address, cookies=flextv_cookie, @@ -841,7 +843,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: port_info = json_data else: @@ -850,7 +852,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("look.163.com/") > -1: platform = 'Look直播' with semaphore: - port_info = asyncio.run(spider.get_looklive_stream_url( + port_info = run_async_coro(spider.get_looklive_stream_url( url=record_url, proxy_addr=proxy_address, cookies=look_cookie )) @@ -858,7 +860,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: platform = 'PopkonTV' with semaphore: if global_proxy or proxy_address: - port_info = asyncio.run(spider.get_popkontv_stream_url( + port_info = run_async_coro(spider.get_popkontv_stream_url( url=record_url, proxy_addr=proxy_address, access_token=popkontv_access_token, @@ -878,7 +880,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("twitcasting.tv/") > -1: platform = 'TwitCasting' with semaphore: - json_data = asyncio.run(spider.get_twitcasting_stream_url( + json_data = run_async_coro(spider.get_twitcasting_stream_url( url=record_url, proxy_addr=proxy_address, cookies=twitcasting_cookie, @@ -886,7 +888,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: username=twitcasting_username, password=twitcasting_password )) - port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=False)) if port_info and port_info.get('new_cookies'): utils.update_config( @@ -897,36 +899,36 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("live.baidu.com/") > -1: platform = '百度直播' with semaphore: - json_data = asyncio.run(spider.get_baidu_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_weibo_stream_data( url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问TwitchTV直播平台") @@ -934,7 +936,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: if global_proxy or proxy_address: platform = 'LiveMe' with semaphore: - port_info = asyncio.run(spider.get_liveme_stream_url( + port_info = run_async_coro(spider.get_liveme_stream_url( url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问LiveMe直播平台") @@ -942,113 +944,113 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("www.huajiao.com/") > -1: platform = '花椒直播' with semaphore: - port_info = asyncio.run(spider.get_huajiao_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_acfun_stream_data( url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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']) @@ -1056,16 +1058,16 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_taobao_stream_url( url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(stream.get_stream_url( json_data, record_quality, url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl' )) @@ -1073,7 +1075,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + port_info = run_async_coro(spider.get_jd_stream_url( url=record_url, proxy_addr=proxy_address, cookies=jd_cookie)) elif record_url.find("faceit.com/") > -1: @@ -1081,34 +1083,34 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: with semaphore: if global_proxy or proxy_address: with semaphore: - json_data = asyncio.run(spider.get_faceit_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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: diff --git a/scripts/hardware_probe.py b/scripts/hardware_probe.py index 9cae8ff..6bf46be 100644 --- a/scripts/hardware_probe.py +++ b/scripts/hardware_probe.py @@ -146,15 +146,29 @@ def build_profile() -> dict: def suggest_video_encoder(profile: dict) -> str: - """在 Linux 上根据 ffmpeg 能力与探测结果给出可选硬件编码器名(供 douyin→YouTube 读取 LIVE_VIDEO_ENCODER)。""" + """在 Linux 上根据 ffmpeg 能力与探测结果给出可选硬件编码器名(供 douyin→YouTube 读取 LIVE_VIDEO_ENCODER)。 + + 优先级:RKMPP(常见 ARM SoC)→ V4L2M2M(通用 ARM/部分板)→ VA-API(x86 核显)→ QSV(Intel x86)。 + NVIDIA 由 douyin_youtube_ffplay 内单独检测 h264_nvenc,此处不重复写入。 + """ + if platform.system().lower() != "linux": + return "" text = run_text(["ffmpeg", "-hide_banner", "-encoders"]) if not text: return "" hwaccels = set(profile.get("ffmpeg_hwaccels") or []) + gpu = profile.get("gpu") or {} + render_ok = bool(gpu.get("render_nodes")) + machine = platform.machine().lower() + if "h264_rkmpp" in text and Path("/dev/mpp_service").exists(): return "h264_rkmpp" if "h264_v4l2m2m" in text and "v4l2m2m" in hwaccels: return "h264_v4l2m2m" + if "h264_vaapi" in text and render_ok and "vaapi" in hwaccels: + return "h264_vaapi" + if machine in ("x86_64", "amd64") and "h264_qsv" in text: + return "h264_qsv" return "" diff --git a/src/async_thread_loop.py b/src/async_thread_loop.py new file mode 100644 index 0000000..157b54e --- /dev/null +++ b/src/async_thread_loop.py @@ -0,0 +1,18 @@ +"""Per-thread asyncio event loop reuse (avoids asyncio.run() overhead in recorder threads).""" +from __future__ import annotations + +import asyncio +import threading +from typing import Any, Coroutine, TypeVar + +_T = TypeVar("_T") +_tls = threading.local() + + +def run_async_coro(coro: Coroutine[Any, Any, _T]) -> _T: + loop: asyncio.AbstractEventLoop | None = getattr(_tls, "loop", None) + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + _tls.loop = loop + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) diff --git a/src/hub_routes.py b/src/hub_routes.py index 7bc8d50..74c0aef 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any, Optional from fastapi import APIRouter @@ -19,22 +20,30 @@ from src.control_plane import ( ) from src.live_config import business_config, load_hub_config_snapshot, services_config_list from src.web_process_backend import WebProcessBackend +from src.youtube_ini_sync import write_youtube_ini_stream_key router = APIRouter(prefix="/hub", tags=["Live Hub"]) _process_backend: WebProcessBackend | None = None _monitor_entries: list[dict[str, Any]] = [] +_repo_root: Path | None = None -def configure_hub(process_backend: WebProcessBackend, monitor_entries: list[dict[str, Any]]) -> None: - global _process_backend, _monitor_entries +def configure_hub( + process_backend: WebProcessBackend, + monitor_entries: list[dict[str, Any]], + repo_root: Optional[Path] = None, +) -> None: + global _process_backend, _monitor_entries, _repo_root _process_backend = process_backend _monitor_entries = list(monitor_entries) + _repo_root = repo_root.resolve() if repo_root is not None else None class BusinessDouyinPatch(BaseModel): streams: Optional[dict[str, str]] = None ffmpeg: Optional[dict[str, Any]] = None + sync_youtube_ini: Optional[bool] = None @router.get("/dashboard") @@ -142,4 +151,15 @@ async def hub_business_douyin_patch(body: BusinessDouyinPatch) -> dict[str, str] cur_ff.update(body.ffmpeg) current["ffmpeg"] = cur_ff path.write_text(json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - return {"message": "business/douyinyoutube.json updated"} + msg = "business/douyinyoutube.json updated" + if body.sync_youtube_ini and _repo_root is not None: + streams = current.get("streams") if isinstance(current.get("streams"), dict) else {} + raw_key = streams.get("youtube_rtmp_key") + key_val = raw_key.strip() if isinstance(raw_key, str) else "" + if key_val: + msg = f"{msg}; {write_youtube_ini_stream_key(_repo_root, key_val)}" + else: + msg = f"{msg}; sync_youtube_ini skipped (no youtube_rtmp_key)" + elif body.sync_youtube_ini and _repo_root is None: + msg = f"{msg}; sync_youtube_ini skipped (repo root not configured)" + return {"message": msg} diff --git a/src/web_process_backend.py b/src/web_process_backend.py index 21f2dfa..27a6ef1 100644 --- a/src/web_process_backend.py +++ b/src/web_process_backend.py @@ -49,8 +49,7 @@ async def run_pm2(cmd: str, timeout: float = 15.0) -> str: return text -def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]: - """仅匹配与本项目直播栈相关的 ffmpeg(命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg。""" +def _ffmpeg_cmdline_markers(process_name: str, project_root: Path | None) -> list[str]: markers: list[str] = [ process_name, "a.rtmp.youtube.com", @@ -64,7 +63,12 @@ def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) - markers.append(str(project_root.resolve())) except OSError: pass - markers = [m for m in markers if m] + return [m for m in markers if m] + + +def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]: + """仅匹配与本项目直播栈相关的 ffmpeg(命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg。""" + markers = _ffmpeg_cmdline_markers(process_name, project_root) seen: set[int] = set() proc_root = Path("/proc") if not proc_root.is_dir(): @@ -97,16 +101,45 @@ def _signal_pids(pids: Iterable[int], sig: int) -> None: pass -async def force_kill_ffmpeg(process_name: str, project_root: Path | None = None) -> None: - """结束与本业务相关的 ffmpeg:Linux 按 /proc 命令行匹配 + 可选 pkill;不再使用 killall -9 ffmpeg。""" - if sys.platform == "win32": - _ = process_name - proc2 = await asyncio.create_subprocess_shell( - "taskkill /IM ffmpeg.exe /F /T 2>nul || exit /b 0", - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, +def _windows_kill_stack_ffmpeg(process_name: str, project_root: Path | None) -> None: + """仅结束命令行命中业务标记的 ffmpeg.exe,避免 taskkill /IM 误杀其它 FFmpeg。""" + markers = _ffmpeg_cmdline_markers(process_name, project_root) + if not markers: + return + env = os.environ.copy() + env["DOUYIN_FFMPEG_MARKERS"] = json.dumps(markers, ensure_ascii=False) + ps = r""" +$raw = $env:DOUYIN_FFMPEG_MARKERS +if (-not $raw) { exit 0 } +$m = $raw | ConvertFrom-Json +foreach ($proc in Get-CimInstance Win32_Process -Filter "Name = 'ffmpeg.exe'") { + $cmd = $proc.CommandLine + if (-not $cmd) { continue } + foreach ($x in $m) { + if ($cmd -like ('*' + $x + '*')) { + Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue + break + } + } +} +""" + try: + subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], + env=env, + capture_output=True, + text=True, + timeout=45, + check=False, ) - await proc2.wait() + except (OSError, subprocess.TimeoutExpired): + pass + + +async def force_kill_ffmpeg(process_name: str, project_root: Path | None = None) -> None: + """结束与本业务相关的 ffmpeg:Linux 按 /proc 命令行匹配 + 可选 pkill;Windows 按命令行标记筛选。""" + if sys.platform == "win32": + await asyncio.to_thread(_windows_kill_stack_ffmpeg, process_name, project_root) return def collect() -> list[int]: diff --git a/src/youtube_ini_sync.py b/src/youtube_ini_sync.py new file mode 100644 index 0000000..8bb0bd9 --- /dev/null +++ b/src/youtube_ini_sync.py @@ -0,0 +1,24 @@ +"""Write YouTube stream key into repo config/youtube.ini while preserving other keys.""" +from __future__ import annotations + +import configparser +from pathlib import Path + + +def write_youtube_ini_stream_key(repo_root: Path, stream_key_or_url: str) -> str: + root = repo_root.resolve() + cfg_path = root / "config" / "youtube.ini" + value = (stream_key_or_url or "").strip() + if not value: + return "sync skipped: empty youtube_rtmp_key" + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + parser = configparser.ConfigParser() + if cfg_path.is_file(): + parser.read(cfg_path, encoding="utf-8") + if not parser.has_section("youtube"): + parser.add_section("youtube") + parser.set("youtube", "key", value) + with cfg_path.open("w", encoding="utf-8") as fh: + parser.write(fh) + return f"wrote {cfg_path}" diff --git a/tiktok.py b/tiktok.py index b7cd0be..82a202c 100644 --- a/tiktok.py +++ b/tiktok.py @@ -13,6 +13,8 @@ from __future__ import annotations import asyncio import os + +from src.async_thread_loop import run_async_coro import sys import builtins import subprocess @@ -673,27 +675,27 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + json_data = run_async_coro(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( + json_data = run_async_coro(spider.get_douyin_app_stream_data( url=record_url, proxy_addr=proxy_address, cookies=dy_cookie)) - port_info = asyncio.run( + port_info = run_async_coro( 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( + json_data = run_async_coro(spider.get_tiktok_stream_data( url=record_url, proxy_addr=proxy_address, cookies=tiktok_cookie)) - port_info = asyncio.run( + port_info = run_async_coro( stream.get_tiktok_stream_url(json_data, record_quality, proxy_address)) else: logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台") @@ -701,23 +703,23 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("https://live.kuaishou.com/") > -1: platform = '快手直播' with semaphore: - json_data = asyncio.run(spider.get_kuaishou_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_huya_stream_url(json_data, record_quality)) else: - port_info = asyncio.run(spider.get_huya_app_stream_url( + port_info = run_async_coro(spider.get_huya_app_stream_url( url=record_url, proxy_addr=proxy_address, cookies=hy_cookie @@ -726,52 +728,52 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("https://www.douyu.com/") > -1: platform = '斗鱼直播' with semaphore: - json_data = asyncio.run(spider.get_douyu_info_data( + json_data = run_async_coro(spider.get_douyu_info_data( url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie)) - port_info = asyncio.run(stream.get_douyu_stream_url( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_bilibili_room_info( url=record_url, proxy_addr=proxy_address, cookies=bili_cookie)) - port_info = asyncio.run(stream.get_bilibili_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_sooplive_stream_data( url=record_url, proxy_addr=proxy_address, cookies=sooplive_cookie, username=sooplive_username, @@ -781,51 +783,51 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_netease_stream_data( url=record_url, cookies=netease_cookie)) - port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问WinkTV直播平台") @@ -833,7 +835,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: platform = 'FlexTV' with semaphore: if global_proxy or proxy_address: - json_data = asyncio.run(spider.get_flextv_stream_data( + json_data = run_async_coro(spider.get_flextv_stream_data( url=record_url, proxy_addr=proxy_address, cookies=flextv_cookie, @@ -845,7 +847,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: port_info = json_data else: @@ -854,7 +856,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("look.163.com/") > -1: platform = 'Look直播' with semaphore: - port_info = asyncio.run(spider.get_looklive_stream_url( + port_info = run_async_coro(spider.get_looklive_stream_url( url=record_url, proxy_addr=proxy_address, cookies=look_cookie )) @@ -862,7 +864,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: platform = 'PopkonTV' with semaphore: if global_proxy or proxy_address: - port_info = asyncio.run(spider.get_popkontv_stream_url( + port_info = run_async_coro(spider.get_popkontv_stream_url( url=record_url, proxy_addr=proxy_address, access_token=popkontv_access_token, @@ -882,7 +884,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("twitcasting.tv/") > -1: platform = 'TwitCasting' with semaphore: - json_data = asyncio.run(spider.get_twitcasting_stream_url( + json_data = run_async_coro(spider.get_twitcasting_stream_url( url=record_url, proxy_addr=proxy_address, cookies=twitcasting_cookie, @@ -890,7 +892,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: username=twitcasting_username, password=twitcasting_password )) - port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=False)) if port_info and port_info.get('new_cookies'): utils.update_config( @@ -901,36 +903,36 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("live.baidu.com/") > -1: platform = '百度直播' with semaphore: - json_data = asyncio.run(spider.get_baidu_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_weibo_stream_data( url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(stream.get_stream_url(json_data, record_quality, spec=True)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问TwitchTV直播平台") @@ -938,7 +940,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: if global_proxy or proxy_address: platform = 'LiveMe' with semaphore: - port_info = asyncio.run(spider.get_liveme_stream_url( + port_info = run_async_coro(spider.get_liveme_stream_url( url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie)) else: logger.error("错误信息: 网络异常,请检查本网络是否能正常访问LiveMe直播平台") @@ -946,113 +948,113 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: elif record_url.find("www.huajiao.com/") > -1: platform = '花椒直播' with semaphore: - port_info = asyncio.run(spider.get_huajiao_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_acfun_stream_data( url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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']) @@ -1060,16 +1062,16 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + json_data = run_async_coro(spider.get_taobao_stream_url( url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie)) - port_info = asyncio.run(stream.get_stream_url( + port_info = run_async_coro(stream.get_stream_url( json_data, record_quality, url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl' )) @@ -1077,7 +1079,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: 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( + port_info = run_async_coro(spider.get_jd_stream_url( url=record_url, proxy_addr=proxy_address, cookies=jd_cookie)) elif record_url.find("faceit.com/") > -1: @@ -1085,34 +1087,34 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: with semaphore: if global_proxy or proxy_address: with semaphore: - json_data = asyncio.run(spider.get_faceit_stream_data( + json_data = run_async_coro(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)) + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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( + port_info = run_async_coro(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: diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 58fe635..48d095a 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -26,14 +26,21 @@ REPO_ROOT = Path(__file__).resolve().parent.parent FILES = [ "src/android_control.py", + "src/async_thread_loop.py", + "src/youtube_ini_sync.py", "src/control_plane.py", "src/web_process_backend.py", "src/hub_routes.py", "src/live_config/__init__.py", "src/live_config/loader.py", "web.py", + "youtube.py", + "tiktok.py", "main.py", "scripts/hardware_probe.py", + "config/hardware.env.example", + "ecosystem.config.cjs", + "docs/install-profiles.md", "douyin_youtube_ffplay.py", "scripts/linux/install_hub.sh", "scripts/linux/reinstall_live_edge.sh", @@ -58,6 +65,7 @@ FILES = [ "web-console/components/live-product/LiveControlApp.tsx", "web-console/components/live-product/LiveAndroidWorkstation.tsx", "web-console/components/live-product/LiveYoutubeUnmannedView.tsx", + "web-console/components/live-product/LiveProcessLiveLogCard.tsx", "web-console/components/live-product/LiveTiktokHdmiView.tsx", "web-console/lib/panelUrls.ts", "web-console/lib/youtubeProChannels.ts", @@ -222,8 +230,9 @@ def main() -> int: _safe_print(f"已上传: {rel}") py_compile = ( - f"cd {REMOTE_BASE} && python3 -m py_compile web.py " + f"cd {REMOTE_BASE} && python3 -m py_compile web.py youtube.py tiktok.py " "src/control_plane.py src/web_process_backend.py src/hub_routes.py " + "src/async_thread_loop.py src/youtube_ini_sync.py " "src/live_config/__init__.py src/live_config/loader.py " "scripts/hardware_probe.py douyin_youtube_ffplay.py main.py" ) diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx index e663b9d..11003f7 100644 --- a/web-console/components/live-product/LiveAndroidWorkstation.tsx +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -1,6 +1,6 @@ "use client"; -import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react"; +import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; type DeviceRow = { serial: string; model?: string; state: string }; @@ -84,9 +84,14 @@ export function LiveAndroidWorkstation({ const [openUrl, setOpenUrl] = useState("https://"); const [adbInputText, setAdbInputText] = useState(""); const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host"); + const [mirrorReloadKey, setMirrorReloadKey] = useState(0); + const [logcatLive, setLogcatLive] = useState(false); const [pending, setPending] = useState(null); const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/"; + const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy"; + const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy"; + const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy"; const lock = !!(busy || pending); @@ -157,26 +162,36 @@ export function LiveAndroidWorkstation({ } }; - const refreshLogcat = async () => { - if (!androidSerial) { - notify(t("请选择设备", "Pick a device")); - return; - } - const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200)); - setPending("logcat"); - setLogcatErr(null); - try { - const data = await fetchJson<{ text?: string }>( - `/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`, - ); - setLogcatText(data.text ?? ""); - } catch (e) { - setLogcatText(""); - setLogcatErr((e as Error).message); - } finally { - setPending(null); - } - }; + const refreshLogcat = useCallback( + async (opts?: { silent?: boolean }) => { + if (!androidSerial) { + return; + } + const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200)); + const silent = !!opts?.silent; + if (!silent) setPending("logcat"); + setLogcatErr(null); + try { + const data = await fetchJson<{ text?: string }>( + `/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`, + ); + setLogcatText(data.text ?? ""); + } catch (e) { + setLogcatText(""); + setLogcatErr((e as Error).message); + } finally { + if (!silent) setPending(null); + } + }, + [androidSerial, fetchJson, logcatLines], + ); + + useEffect(() => { + if (!logcatLive || !androidSerial || !adbAvailable) return; + const id = window.setInterval(() => void refreshLogcat({ silent: true }), 3000); + void refreshLogcat({ silent: true }); + return () => clearInterval(id); + }, [logcatLive, androidSerial, adbAvailable, refreshLogcat]); const loadUiNodes = async () => { if (!androidSerial) { @@ -260,10 +275,10 @@ export function LiveAndroidWorkstation({ ))} {adbAvailable && devices.length === 0 ? ( -

+

{t( - "未检测到已授权设备:请 USB 连接并开启「USB 调试」,手机上允许本计算机调试。", - "No authorized device: enable USB debugging and authorize this computer.", + "未检测到已授权设备:双头 USB 接板子时请开「USB 调试」并授权;Redroid / 容器安卓可先 `adb connect IP:5555` 再刷新本页。", + "No device: enable USB debugging for dual-USB to the board; for Redroid use adb connect IP:5555 then refresh.", )}

) : null} @@ -293,22 +308,40 @@ export function LiveAndroidWorkstation({ > WebUSB · Panda - - GitHub - +
+ + scrcpy + + + ws-scrcpy + + + panda-web-scrcpy + +
{mirrorMode === "host" ? (
-

+

{t( - "手机接在 live 板子 USB 时使用本页:先在 ws-scrcpy 里选设备并连接;黑屏多为未连 WebSocket / 屏幕休眠——可先 Wake 再刷新或新窗口打开面板。", - "USB to the board: pick device in ws-scrcpy; black screen often means WS down or screen off—Wake, refresh, or open panel in a new tab.", + "板载 USB:投屏协议来自开源 scrcpy(见上方仓库链接);浏览器内常用 ws-scrcpy。黑屏时先 Wake,再点「重载投屏」或新窗口打开面板。", + "Board USB: scrcpy protocol; browser uses ws-scrcpy. Black screen: Wake, reload mirror, or open panel in new tab.", )}

@@ -323,19 +356,30 @@ export function LiveAndroidWorkstation({ ) : null} {scrcpyUrl ? ( - - {t("新窗口打开 :5000", "Open :5000 tab")} - + <> + + + {t("新窗口打开", "New tab")} + + ) : null}
{scrcpyUrl ? (