From 22061fb3c8149b82ab9290a0732731b5c225ae8e Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 29 Mar 2026 03:35:05 -0500 Subject: [PATCH] 's' --- douyin_youtube_ffplay.py | 80 +++-- src/android_control.py | 24 +- src/control_plane.py | 3 + tiktok.py | 78 ++++- .../live-product/LiveAndroidWorkstation.tsx | 7 + .../live-product/LiveControlApp.tsx | 27 +- .../live-product/LivePipelineStrip.tsx | 3 +- .../live-product/LiveTiktokHdmiView.tsx | 281 +++++++++++++++++- .../live-product/LiveYoutubeUnmannedView.tsx | 5 +- web-console/lib/youtubeConfigUtils.ts | 4 +- web-console/lib/youtubeProChannels.ts | 37 ++- web-console/middleware.ts | 2 + web.py | 193 +++++++++++- youtube.py | 19 +- 14 files changed, 669 insertions(+), 94 deletions(-) diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index d6ffe16..f63a0c6 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -315,32 +315,60 @@ def start_douyin_youtube_ffplay( if codec_v == "libx264": _video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"] # -re:按源时间戳节奏拉流,避免突发过快;不用 realtime/arealtime(输入抖动时易让 YouTube 判为数据不足) - ffmpeg_command = [ - "ffmpeg", "-y", "-loglevel", "info", "-nostdin", - "-stats", "-stats_period", "1", - "-rw_timeout", "50000000", - "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", - "-reconnect_delay_max", str(RECONNECT_DELAY_MAX), - "-fflags", "+genpts+discardcorrupt", - "-err_detect", "ignore_err", - "-max_delay", "500000", - "-thread_queue_size", "2048", - "-filter_threads", _filter_threads, - "-headers", douyin_headers, - "-re", - "-i", real_url, - # bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x - "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", - "-c:v", codec_v, - *video_args, - *_video_mid, - "-pix_fmt", "yuv420p", - "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", - "-af", _af_chain, - "-flags", "+global_header", - "-flvflags", "no_duration_filesize", - "-f", "flv", youtube_rtmp - ] + _vf_pad = ( + "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear," + "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black" + ) + _input_path = os.path.normpath(os.path.expanduser(real_url)) + _input_is_file = os.path.isfile(_input_path) + if _input_is_file: + ffmpeg_command = [ + "ffmpeg", "-y", "-loglevel", "info", "-nostdin", + "-stats", "-stats_period", "1", + "-fflags", "+genpts+discardcorrupt", + "-err_detect", "ignore_err", + "-thread_queue_size", "2048", + "-filter_threads", _filter_threads, + "-re", + "-i", _input_path, + "-vf", _vf_pad, + "-c:v", codec_v, + *video_args, + *_video_mid, + "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-af", _af_chain, + "-flags", "+global_header", + "-flvflags", "no_duration_filesize", + "-f", "flv", youtube_rtmp, + ] + else: + ffmpeg_command = [ + "ffmpeg", "-y", "-loglevel", "info", "-nostdin", + "-stats", "-stats_period", "1", + "-rw_timeout", "50000000", + "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", + "-reconnect_delay_max", str(RECONNECT_DELAY_MAX), + "-fflags", "+genpts+discardcorrupt", + "-err_detect", "ignore_err", + "-max_delay", "500000", + "-thread_queue_size", "2048", + "-filter_threads", _filter_threads, + "-headers", douyin_headers, + "-re", + "-i", real_url, + # bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x + "-vf", _vf_pad, + "-c:v", codec_v, + *video_args, + *_video_mid, + "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", + "-af", _af_chain, + "-flags", "+global_header", + "-flvflags", "no_duration_filesize", + "-f", "flv", youtube_rtmp, + ] _require_ffmpeg_stats_period() diff --git a/src/android_control.py b/src/android_control.py index bed3b12..5aadf87 100644 --- a/src/android_control.py +++ b/src/android_control.py @@ -431,16 +431,34 @@ async def android_logcat_recent(serial: str, lines: int = 200) -> str: return await _shell(serial, f"logcat -d -t {n}", timeout=60.0) +def _strip_to_png(data: bytes) -> bytes: + if not data: + return data + stripped = data.lstrip().replace(b"\r\n", b"\n") + sig = b"\x89PNG\r\n\x1a\n" + idx = stripped.find(sig) + if idx > 0: + stripped = stripped[idx:] + if stripped.startswith(b"\n\x89PNG"): + stripped = stripped[1:] + return stripped + + async def android_screenshot(serial: str, *, exec_timeout: float = 14.0) -> bytes: if not serial: raise ValueError("Missing Android device serial") code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=exec_timeout) if code == 0 and data: - normalized = data.replace(b"\r\n", b"\n") - if _is_png(data) or normalized.startswith(b"\x89PNG"): + normalized = _strip_to_png(data) + if _is_png(normalized): return normalized - last_err = err or "exec-out screencap failed" + code_sh, data_sh, err_sh = await _adb_binary(serial, "shell", "screencap", "-p", timeout=exec_timeout) + if code_sh == 0 and data_sh: + normalized_sh = _strip_to_png(data_sh) + if _is_png(normalized_sh): + return normalized_sh + last_err = err_sh or err or "screencap failed" for remote in ("/data/local/tmp/live-cap.png", "/sdcard/live-cap.png"): tmp = Path(tempfile.mkstemp(suffix=".png")[1]) try: diff --git a/src/control_plane.py b/src/control_plane.py index b1c7ba9..740ec85 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -619,6 +619,7 @@ def stack_summary() -> dict: netd = env.get("NETDATA_PORT", "19999") srs_http = env.get("SRS_HTTP_PORT", "8080") srs_api = env.get("SRS_API_PORT", "1985") + srs_rtmp = env.get("SRS_RTMP_PORT", "1935") android_panel = env.get("ANDROID_PANEL_PORT", "5000") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") neko_port = env.get("NEKO_HTTP_PORT", "9200") @@ -628,6 +629,8 @@ def stack_summary() -> dict: "filebrowser": f"http://{mdns_host}:{fb}", "netdata": f"http://{mdns_host}:{netd}", "srs_http": f"http://{mdns_host}:{srs_http}/", + "srs_rtmp_publish": f"rtmp://{mdns_host}:{srs_rtmp}/live/livestream", + "srs_play_flv": f"http://{mdns_host}:{srs_http}/live/livestream.flv", "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", "ws_scrcpy": f"http://{mdns_host}:{android_panel}", "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", diff --git a/tiktok.py b/tiktok.py index bae3bd7..53055ef 100644 --- a/tiktok.py +++ b/tiktok.py @@ -76,7 +76,27 @@ 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' + + +def _tiktok_live_pm2_name() -> str: + return (os.environ.get("LIVE_PM2_PROCESS_NAME") or os.environ.get("PM2_SERVICE_NAME") or "").strip() + + +def _tiktok_url_config_file() -> str: + legacy = f"{script_path}/config/URL_config.ini" + pm2 = _tiktok_live_pm2_name() + if not pm2 or not pm2.lower().startswith("tiktok"): + return legacy + safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2) + per = f"{script_path}/config/URL_config.{safe}.ini" + if os.path.isfile(per): + return per + if os.path.isfile(legacy): + return legacy + return per + + +url_config_file = _tiktok_url_config_file() backup_dir = f'{script_path}/backup_config' text_encoding = 'utf-8-sig' rstr = r"[\/\\\:\*\??\"\<\>\|&#.。,, ~!· ]" @@ -610,6 +630,9 @@ def is_flv_preferred_platform(link): def select_source_url(link, stream_info): + if isinstance(link, str) and link.lower().startswith("replayfile:"): + p = link.split(":", 1)[1].strip() + return os.path.normpath(p.replace("/", os.sep)) if is_flv_preferred_platform(link): codec = utils.get_query_params(stream_info.get('flv_url'), "codec") if codec and codec[0] == 'h265': @@ -655,7 +678,23 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: while True: try: port_info = [] - if record_url.find("douyin.com/") > -1: + if record_url.lower().startswith("replayfile:"): + platform = "录播素材" + fp = os.path.normpath(record_url.split(":", 1)[1].strip().replace("/", os.sep)) + if not os.path.isfile(fp): + logger.error(f"录播素材文件不存在或不可读: {fp}") + time.sleep(5) + continue + bn = os.path.splitext(os.path.basename(fp))[0][:80] or "replay" + port_info = { + "anchor_name": f"录播_{bn}", + "is_live": True, + "record_url": record_url, + "title": os.path.basename(fp), + "m3u8_url": None, + "flv_url": None, + } + elif record_url.find("douyin.com/") > -1: platform = '抖音直播' with semaphore: if 'v.douyin.com' not in record_url and '/user/' not in record_url: @@ -1232,7 +1271,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: except Exception as e: logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}") - if platform != '自定义录制直播': + if platform not in ('自定义录制直播', '录播素材'): if enable_https_recording and real_url.startswith("http://"): real_url = real_url.replace("http://", "https://") @@ -1258,6 +1297,9 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: max_muxing_queue_size = "2048" break + if platform == "录播素材": + rw_timeout = "50000000" + ffmpeg_command = [ 'ffmpeg', "-y", "-v", "verbose", @@ -1281,11 +1323,11 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: ] headers = get_record_headers(platform, record_url) - if headers: + if headers and platform != "录播素材": ffmpeg_command.insert(11, "-headers") ffmpeg_command.insert(12, headers) - if proxy_address: + if proxy_address and not record_url.lower().startswith("replayfile:"): ffmpeg_command.insert(1, "-http_proxy") ffmpeg_command.insert(2, proxy_address) @@ -2003,7 +2045,7 @@ while True: delete_line(url_config_file, origin_line) line_list.append(origin_line) line = origin_line.strip() - if len(line) < 18: + if len(line) < 12 or (len(line) < 18 and "replayfile:" not in line.lower()): continue line_spilt = line.split('主播: ') @@ -2040,8 +2082,22 @@ while True: else: delete_line(url_config_file, origin_line) - url = 'https://' + url if '://' not in url else url - url_host = url.split('/')[2] + url_stripped = url.strip() + _rp = "replayfile:" + if url_stripped.lower().startswith(_rp): + _path_part = url_stripped[len(_rp) :].strip().replace("/", os.sep) + _norm = os.path.normpath(_path_part) + if not os.path.isfile(_norm): + color_obj.print_colored(f"\r{origin_line.strip()} 录播文件不存在,跳过", color_obj.YELLOW) + continue + url = _rp + _norm.replace("\\", "/") + url_host = "__replay_local__" + elif "://" not in url_stripped: + url = "https://" + url_stripped + url_host = url.split("/")[2] + else: + url = url_stripped + url_host = url.split("/")[2] platform_host = [ 'live.douyin.com', @@ -2139,7 +2195,11 @@ while True: 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 platform_host + or any(ext in url for ext in (".flv", ".m3u8")) + or url_host == "__replay_local__" + ): if url_host in clean_url_host_list: url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0]) diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx index dbe9390..ecf6af1 100644 --- a/web-console/components/live-product/LiveAndroidWorkstation.tsx +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -275,6 +275,13 @@ export function LiveAndroidWorkstation({ ) : null}
{d.serial}
+ {(d.state || "").toLowerCase() !== "device" ? ( +
+ {d.state === "unauthorized" + ? t("未授权:请在手机上点「允许 USB 调试」", "Unauthorized — allow USB debugging on device") + : t(`状态: ${d.state}`, `State: ${d.state}`)} +
+ ) : null} ))} diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx index 3ed4e45..4b5e1b3 100644 --- a/web-console/components/live-product/LiveControlApp.tsx +++ b/web-console/components/live-product/LiveControlApp.tsx @@ -8,7 +8,6 @@ import { CirclePlay, Clock, Cpu, - FolderOpen, HardDrive, Languages, Loader2, @@ -31,7 +30,6 @@ import { LiveStudioScene3D } from "@/components/LiveStudioScene3D"; import OverviewCharts from "@/components/OverviewCharts"; import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace"; import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation"; -import { LiveFilebrowserPanel } from "@/components/live-product/LiveFilebrowserPanel"; import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView"; import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip"; import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView"; @@ -42,7 +40,6 @@ type PortalTheme = "dark" | "light"; type ViewKey = | "dashboard" | "services" - | "files" | "live_youtube" | "live_tiktok" | "android" @@ -218,7 +215,10 @@ export default function LiveControlApp() { setDash(d); setDashErr(null); const devs = d.android?.devices || []; - if (devs.length && !androidSerial) setAndroidSerial(devs[0].serial); + if (devs.length && !androidSerial) { + const ready = devs.find((x) => (x.state || "").toLowerCase() === "device"); + setAndroidSerial((ready ?? devs[0]).serial); + } } catch (e) { setDashErr((e as Error).message); } finally { @@ -276,7 +276,7 @@ export default function LiveControlApp() { }, [currentProc, fetchJson]); useEffect(() => { - if (!currentProc || (view !== "live_youtube" && view !== "live_tiktok")) return; + if (!currentProc || view !== "live_youtube") return; void (async () => { try { const data = await fetchJson<{ content?: string }>( @@ -395,7 +395,6 @@ export default function LiveControlApp() { const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [ { id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" }, { id: "services", icon: Server, labelZh: "服务", labelEn: "Services" }, - { id: "files", icon: FolderOpen, labelZh: "文件(File Browser)", labelEn: "Files" }, { id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" }, { id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" }, { id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" }, @@ -1047,14 +1046,6 @@ export default function LiveControlApp() { ) : null} - {view === "files" ? ( - - ) : null} - {view === "services" ? (
{(dash?.services || []).map((s, i) => ( @@ -1186,8 +1177,16 @@ export default function LiveControlApp() { setUrlConfig={setUrlConfig} onRunProcess={(a) => void runProcess(a)} onSaveUrlConfig={(c) => saveUrlConfig(c)} + onReplayGoLive={async (replayUrl, label) => { + const safe = label.replace(/[,,|]/g, "_"); + await saveUrlConfig(`原画,${replayUrl},主播: ${safe}`); + await runProcess("start"); + }} onCopy={copyText} fetchJson={fetchJson} + apiPrefix={base} + quickLinks={ql} + notify={notify} onNavigate={(target: PortalNavTarget) => setView(target)} /> ) : null} diff --git a/web-console/components/live-product/LivePipelineStrip.tsx b/web-console/components/live-product/LivePipelineStrip.tsx index 40e40bf..8f0070c 100644 --- a/web-console/components/live-product/LivePipelineStrip.tsx +++ b/web-console/components/live-product/LivePipelineStrip.tsx @@ -7,8 +7,7 @@ export type PortalNavTarget = | "services" | "live_youtube" | "live_tiktok" - | "android" - | "files"; + | "android"; type TFn = (zh: string, en: string) => string; diff --git a/web-console/components/live-product/LiveTiktokHdmiView.tsx b/web-console/components/live-product/LiveTiktokHdmiView.tsx index 2bb93f8..3d12ae2 100644 --- a/web-console/components/live-product/LiveTiktokHdmiView.tsx +++ b/web-console/components/live-product/LiveTiktokHdmiView.tsx @@ -1,7 +1,7 @@ "use client"; -import { Cable, Copy, Monitor, Radio } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Cable, Copy, Film, Monitor, Radio, Trash2, Upload } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip"; import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip"; @@ -46,6 +46,11 @@ const PRESETS: HdmiPreset[] = [ const CHECK_KEY = "live-hub-tiktok-hdmi-check-v1"; const NOTES_KEY = "live-hub-tiktok-hdmi-notes-v1"; +const SOURCE_MODE_KEY = "live-hub-tiktok-source-mode-v1"; + +export type TiktokSourceMode = "ffmpeg" | "replay" | "srs"; + +type ReplayFileRow = { name: string; size: number; replay_url: string }; type Props = { t: TFn; @@ -57,8 +62,13 @@ type Props = { setUrlConfig: (v: string) => void; onRunProcess: (a: "start" | "stop" | "restart") => void; onSaveUrlConfig: (content?: string) => void | Promise; + /** 录播:写入 URL 配置为单条 replayfile 行并启动进程 */ + onReplayGoLive: (replayUrl: string, displayLabel: string) => void | Promise; onCopy: (text: string) => void; fetchJson: (path: string, init?: RequestInit) => Promise; + apiPrefix: string; + quickLinks: Record; + notify: (msg: string) => void; onNavigate?: (target: PortalNavTarget) => void; }; @@ -72,12 +82,20 @@ export function LiveTiktokHdmiView({ setUrlConfig, onRunProcess, onSaveUrlConfig, + onReplayGoLive, onCopy, fetchJson, + apiPrefix, + quickLinks, + notify, onNavigate, }: Props) { const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]); const processOptions = tkEntries.length ? tkEntries : entries; + const [sourceMode, setSourceMode] = useState("ffmpeg"); + const [replayFiles, setReplayFiles] = useState([]); + const [replayLoading, setReplayLoading] = useState(false); + const [uploadBusy, setUploadBusy] = useState(false); const [expanded, setExpanded] = useState("1080p30"); const [selectedPreset, setSelectedPreset] = useState("1080p30"); const [urlReloading, setUrlReloading] = useState(false); @@ -87,6 +105,27 @@ export function LiveTiktokHdmiView({ const [hdmiNotes, setHdmiNotes] = useState(""); const [checks, setChecks] = useState>({}); + const setSourceModePersist = useCallback( + (m: TiktokSourceMode) => { + setSourceMode(m); + try { + window.localStorage.setItem(SOURCE_MODE_KEY, m); + } catch { + /* ignore */ + } + }, + [], + ); + + useEffect(() => { + try { + const r = window.localStorage.getItem(SOURCE_MODE_KEY); + if (r === "ffmpeg" || r === "replay" || r === "srs") setSourceMode(r); + } catch { + /* ignore */ + } + }, []); + useEffect(() => { urlListDirtyRef.current = false; }, [currentProc]); @@ -104,13 +143,39 @@ export function LiveTiktokHdmiView({ }, [currentProc, fetchJson, setUrlConfig]); useEffect(() => { - if (!currentProc || !urlAutoRefresh) return; + if (!currentProc || !urlAutoRefresh || sourceMode !== "ffmpeg") return; const id = window.setInterval(() => { if (urlListDirtyRef.current) return; void pullUrlConfigFromServer(); }, 8000); return () => clearInterval(id); - }, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]); + }, [currentProc, urlAutoRefresh, pullUrlConfigFromServer, sourceMode]); + + useEffect(() => { + if (sourceMode !== "ffmpeg" || !currentProc) return; + void pullUrlConfigFromServer(); + }, [sourceMode, currentProc, pullUrlConfigFromServer]); + + const loadReplayList = useCallback(async () => { + if (!currentProc) return; + setReplayLoading(true); + try { + const data = await fetchJson<{ files?: ReplayFileRow[] }>( + `/tiktok_replay/list?process=${encodeURIComponent(currentProc)}`, + ); + setReplayFiles(data.files ?? []); + } catch (e) { + notify((e as Error).message); + setReplayFiles([]); + } finally { + setReplayLoading(false); + } + }, [currentProc, fetchJson, notify]); + + useEffect(() => { + if (sourceMode !== "replay" || !currentProc) return; + void loadReplayList(); + }, [sourceMode, currentProc, loadReplayList]); useEffect(() => { try { @@ -171,7 +236,48 @@ export function LiveTiktokHdmiView({ } }; - const lock = busy || urlReloading; + const deleteReplayFile = async (name: string) => { + if (!currentProc) return; + setUploadBusy(true); + try { + await fetchJson(`/tiktok_replay/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ process: currentProc, filename: name }), + }); + notify(t("已删除文件", "File removed")); + await loadReplayList(); + } catch (e) { + notify((e as Error).message); + } finally { + setUploadBusy(false); + } + }; + + const onPickUpload = async (e: ChangeEvent) => { + const f = e.target.files?.[0]; + e.target.value = ""; + if (!f || !currentProc) return; + setUploadBusy(true); + try { + const fd = new FormData(); + fd.append("file", f); + const res = await fetch( + `${apiPrefix}/tiktok_replay/upload?process=${encodeURIComponent(currentProc)}`, + { method: "POST", body: fd }, + ); + const data = (await res.json()) as { error?: string; message?: string }; + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + notify(data.message || t("上传成功", "Uploaded")); + await loadReplayList(); + } catch (e) { + notify((e as Error).message); + } finally { + setUploadBusy(false); + } + }; + + const lock = busy || urlReloading || uploadBusy; const presetLabel = PRESETS.find((p) => p.id === selectedPreset); const checklist: { id: string; zh: string; en: string }[] = [ @@ -260,6 +366,26 @@ export function LiveTiktokHdmiView({ ))} + + + {sourceMode === "srs" ? ( +

+ {t( + "此模式不显示 URL_config:请在 OBS 中填写下方 RTMP 推流地址;板端 SRS 拉流上屏仍可能占用 HDMI,与 TikTok 进程互斥规则不变。", + "URL_config is hidden: set the RTMP URL in OBS. SRS pull-to-HDMI may still mutex with TikTok per server rules.", + )} +

+ ) : null}
{( [ @@ -287,6 +413,147 @@ export function LiveTiktokHdmiView({

+ {sourceMode === "replay" ? ( +
+
+

+ + {t("录播素材", "VOD library")} +

+
+ + +
+
+

+ {t( + "文件保存在服务器 config/tiktok_replay/对应进程目录;点「开始直播」会写入 replayfile 行并启动 PM2 进程。停播请用上方「停止直播」。", + "Files under config/tiktok_replay/. Go live writes replayfile + starts PM2. Use Stop above to stop.", + )} +

+ {replayLoading ? ( +

{t("加载中…", "Loading…")}

+ ) : replayFiles.length === 0 ? ( +

{t("暂无文件,请先上传。", "No files yet.")}

+ ) : ( +
    + {replayFiles.map((row) => ( +
  • + {row.name} + {(row.size / (1024 * 1024)).toFixed(1)} MB +
    + + + +
    +
  • + ))} +
+ )} +
+ ) : null} + + {sourceMode === "srs" ? ( +
+

{t("SRS / OBS 推流", "SRS / OBS ingest")}

+

+ {t( + "OBS:设置 → 推流 → 服务选「自定义」,服务器填 RTMP 地址;串流密钥可与 SRS 应用配置一致(默认 live/livestream)。", + "OBS → Stream → Custom: RTMP URL as server; stream key per SRS app (default live/livestream).", + )} +

+
+
+
{t("RTMP 推流地址(OBS 服务器)", "RTMP publish URL")}
+
+ {quickLinks.srs_rtmp_publish || "—"} + {quickLinks.srs_rtmp_publish ? ( + + ) : null} +
+
+
+
{t("HTTP-FLV 预览", "HTTP-FLV preview")}
+
+ {quickLinks.srs_play_flv || quickLinks.srs_http || "—"} + {quickLinks.srs_play_flv || quickLinks.srs_http ? ( + + ) : null} +
+
+
+
{t("SRS 控制台", "SRS web UI")}
+
+ + {quickLinks.srs_http || "—"} + +
+
+
+
+ ) : null} +

@@ -348,6 +615,7 @@ export function LiveTiktokHdmiView({

+ {sourceMode === "ffmpeg" ? (

{t("直播地址列表 · URL_config.ini", "URL_config.ini")}

@@ -406,12 +674,13 @@ export function LiveTiktokHdmiView({
+ ) : null} diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx index 05b9e0d..9011917 100644 --- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx +++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx @@ -17,9 +17,9 @@ import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip"; import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard"; import { defaultPm2NameForChannel, + fetchYoutubeProChannelsRemote, loadYoutubeProChannels, newChannelId, - parseYoutubeProChannelList, pushYoutubeProChannelsRemote, saveYoutubeProChannels, type YoutubeProChannel, @@ -271,8 +271,7 @@ export function LiveYoutubeUnmannedView({ let cancelled = false; void (async () => { try { - const data = await fetchJson<{ channels?: unknown }>("/hub/youtube_pro_channels"); - const fromServer = parseYoutubeProChannelList(data.channels); + const fromServer = await fetchYoutubeProChannelsRemote(fetchJson); if (fromServer.length && !cancelled) { setChannels(fromServer); saveYoutubeProChannels(fromServer); diff --git a/web-console/lib/youtubeConfigUtils.ts b/web-console/lib/youtubeConfigUtils.ts index c9b47f9..1ac88f7 100644 --- a/web-console/lib/youtubeConfigUtils.ts +++ b/web-console/lib/youtubeConfigUtils.ts @@ -116,15 +116,13 @@ export function parseYoutubeIniFields(content: string): YoutubeIniFields { return { key, rtmps, bitrate, fastAudio }; } -/** 与 web.py `managed_url_config_relpath` 一致(app-config 相对路径) */ +/** 与 web.py `managed_url_config_relpath` 一致(每进程独立,避免 YouTube / TikTok 共写同一文件) */ export function managedUrlConfigRelPathForPm2(pm2: string): string { - if (!pm2.includes("__")) return "URL_config.ini"; return `URL_config.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`; } /** 与 web.py `managed_youtube_ini_relpath` 一致 */ export function managedYoutubeIniRelPathForPm2(pm2: string): string { - if (!pm2.includes("__")) return "youtube.ini"; return `youtube.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`; } diff --git a/web-console/lib/youtubeProChannels.ts b/web-console/lib/youtubeProChannels.ts index 7c2703a..d6a31cf 100644 --- a/web-console/lib/youtubeProChannels.ts +++ b/web-console/lib/youtubeProChannels.ts @@ -69,15 +69,42 @@ export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels)); } +const YOUTUBE_PRO_SYNC_PATHS = ["/hub/youtube_pro_channels", "/youtube_pro_channels"] as const; + +/** 从服务器拉多频道列表(依次尝试 /hub 与根路径,兼容反代未转发 /hub 的情况) */ +export async function fetchYoutubeProChannelsRemote( + fetchJson: (path: string, init?: RequestInit) => Promise, +): Promise { + for (const path of YOUTUBE_PRO_SYNC_PATHS) { + try { + const data = await fetchJson<{ channels?: unknown }>(path); + const fromServer = parseYoutubeProChannelList(data.channels); + if (fromServer.length) return fromServer; + } catch { + /* try next */ + } + } + return []; +} + export async function pushYoutubeProChannelsRemote( fetchJson: (path: string, init?: RequestInit) => Promise, channels: YoutubeProChannel[], ): Promise { - await fetchJson("/hub/youtube_pro_channels", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ channels }), - }); + let last: Error | null = null; + for (const path of YOUTUBE_PRO_SYNC_PATHS) { + try { + await fetchJson(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channels }), + }); + return; + } catch (e) { + last = e instanceof Error ? e : new Error(String(e)); + } + } + throw last ?? new Error("youtube_pro_channels sync failed"); } export function newChannelId(): string { diff --git a/web-console/middleware.ts b/web-console/middleware.ts index 774d324..4fc480b 100644 --- a/web-console/middleware.ts +++ b/web-console/middleware.ts @@ -28,6 +28,7 @@ const API_ROOTS = new Set([ "system_snapshot", "android", "hub", + "youtube_pro_channels", ]); export function middleware(request: NextRequest) { @@ -66,5 +67,6 @@ export const config = { "/system_snapshot", "/android/:path*", "/hub/:path*", + "/youtube_pro_channels", ], }; diff --git a/web.py b/web.py index 322bac0..19e37e9 100644 --- a/web.py +++ b/web.py @@ -10,7 +10,7 @@ from typing import Optional from pydantic import BaseModel, Field -from fastapi import FastAPI, Query, Request, WebSocket +from fastapi import FastAPI, File, Query, Request, UploadFile, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles @@ -50,7 +50,8 @@ from src.config_governance import ( restore_backup, save_managed_file as governed_save_managed_file, ) -from src.hub_routes import configure_hub, router as hub_router +from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels +from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes # ---------------- 配置(根目录结构) ---------------- @@ -61,9 +62,28 @@ CONSOLE_OUT = BASE_DIR / "web-console" / "out" MAX_LOG_LINES = 300 +MAX_TIKTOK_REPLAY_UPLOAD = 2 * 1024 * 1024 * 1024 +_TIKTOK_REPLAY_EXT = frozenset({".mp4", ".mkv", ".ts", ".flv", ".mov", ".m4v", ".webm"}) + process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR) +def _sanitize_pm2_dir_fragment(name: str) -> str: + s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip()) + return (s or "default")[:120] + + +def tiktok_replay_dir(pm2: str) -> Path: + d = CONFIG_DIR / "tiktok_replay" / _sanitize_pm2_dir_fragment(pm2) + d.mkdir(parents=True, exist_ok=True) + return d + + +def allows_tiktok_replay_upload(pm2: str) -> bool: + sc = script_for_pm2_or_pro(pm2) + return bool(sc and Path(sc).stem.lower().startswith("tiktok")) + + def _label_short(prefix: str) -> str: return { "tiktok": "TikTok", @@ -238,19 +258,47 @@ def _resolve_pro_style_script(head_l: str) -> Optional[str]: return matches[0] +def _guess_script_from_pm2_alias(pm2: str) -> Optional[str]: + """PM2 名与 ecosystem 脚本 stem 不一致时(如自定义 name),按关键词匹配仓库内脚本。""" + low = (pm2 or "").strip().lower() + if not low: + return None + base = BASE_DIR + web_path = Path(__file__).resolve() + + def pick(glob_pat: str, skip: frozenset[str]) -> Optional[str]: + for p in sorted(base.glob(glob_pat)): + if not p.is_file() or p.resolve() == web_path: + continue + if p.name in skip: + continue + return p.name + return None + + if "douyin_youtube" in low or low.startswith("d2y"): + return pick("douyin_youtube*.py", frozenset({"douyin_youtube_ffplay.py"})) + if "tiktok" in low: + return pick("tiktok*.py", frozenset()) + if "youtube" in low: + return pick("youtube*.py", frozenset()) + return None + + def script_for_pm2_or_pro(pm2: str) -> Optional[str]: s = script_for_pm2(pm2) if s: return s head_l = _pro_script_head(pm2) - if not head_l: - return None - resolved = _resolve_pro_style_script(head_l) - if resolved: - return resolved - for p in PROCESS_MONITOR: - if Path(p["script"]).stem.lower() == head_l: - return p["script"] + if head_l: + resolved = _resolve_pro_style_script(head_l) + if resolved: + return resolved + for p in PROCESS_MONITOR: + if Path(p["script"]).stem.lower() == head_l: + return p["script"] + guess = _guess_script_from_pm2_alias(pm2) + if guess: + return guess return None @@ -267,7 +315,7 @@ def managed_url_config_relpath(pm2: str) -> str: sc = script_for_pm2_or_pro(pm2) if not sc: return "URL_config.ini" - if "__" in pm2 and (is_youtube_script(sc) or is_tiktok_script(sc)): + if is_youtube_script(sc) or is_tiktok_script(sc): return f"URL_config.{_pm2_safe_segment(pm2)}.ini" return "URL_config.ini" @@ -276,9 +324,7 @@ def managed_youtube_ini_relpath(pm2: str) -> Optional[str]: sc = script_for_pm2_or_pro(pm2) if not sc or not is_youtube_script(sc): return None - if "__" in pm2: - return f"youtube.{_pm2_safe_segment(pm2)}.ini" - return "youtube.ini" + return f"youtube.{_pm2_safe_segment(pm2)}.ini" def script_for_pm2(pm2: str) -> Optional[str]: @@ -299,6 +345,22 @@ app = FastAPI(title="多进程录制控制台", lifespan=lifespan) configure_hub(process_backend, list(PROCESS_MONITOR), BASE_DIR) app.include_router(hub_router) + +@app.get("/youtube_pro_channels", include_in_schema=False) +async def alias_youtube_pro_channels_get() -> dict: + """与 /hub/youtube_pro_channels 相同;便于反代只转发根路径时仍能同步多频道列表。""" + return {"channels": get_youtube_pro_channels()} + + +@app.post("/youtube_pro_channels", include_in_schema=False) +async def alias_youtube_pro_channels_post(body: YoutubeProChannelsBody): + try: + set_youtube_pro_channels(body.channels) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + return {"message": "ok"} + + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -350,6 +412,11 @@ class AndroidPairBody(BaseModel): code: str = Field(..., min_length=4, max_length=16) +class TiktokReplayDeleteBody(BaseModel): + process: str = Field(..., min_length=1) + filename: str = Field(..., min_length=1, max_length=512) + + async def read_log_path(path: Optional[str], lines: int) -> str: if not path: return "无日志路径\n" @@ -745,7 +812,13 @@ async def get_config(process: str = Query(..., description="进程名")): ) config_file = CONFIG_DIR / rel - if not config_file.exists(): + if not config_file.is_file(): + legacy = CONFIG_DIR / "youtube.ini" + if legacy.is_file(): + try: + return {"content": legacy.read_text(encoding="utf-8")} + except OSError as e: + return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500) return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"} try: content = config_file.read_text(encoding="utf-8") @@ -786,7 +859,13 @@ async def get_url_config(process: str = Query(..., description="进程名")): rel = managed_url_config_relpath(process) config_file = CONFIG_DIR / rel - if not config_file.exists(): + if not config_file.is_file(): + legacy = CONFIG_DIR / "URL_config.ini" + if legacy.is_file(): + try: + return {"content": legacy.read_text(encoding="utf-8")} + except OSError as e: + return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500) return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"} try: content = config_file.read_text(encoding="utf-8") @@ -819,6 +898,88 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500) +@app.get("/tiktok_replay/list") +async def tiktok_replay_list(process: str = Query(..., min_length=1)): + if not allows_tiktok_replay_upload(process): + return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400) + root = tiktok_replay_dir(process) + files: list[dict[str, str | int]] = [] + try: + for p in sorted(root.iterdir(), key=lambda x: x.name.lower()): + if not p.is_file(): + continue + if p.suffix.lower() not in _TIKTOK_REPLAY_EXT: + continue + st = p.stat() + abs_posix = str(p.resolve()).replace("\\", "/") + files.append( + { + "name": p.name, + "size": int(st.st_size), + "mtime": int(st.st_mtime), + "replay_url": f"replayfile:{abs_posix}", + } + ) + except OSError as e: + return JSONResponse({"error": f"列出文件失败: {e}"}, status_code=500) + return {"files": files} + + +@app.post("/tiktok_replay/upload") +async def tiktok_replay_upload( + process: str = Query(..., min_length=1), + file: UploadFile = File(...), +): + if not allows_tiktok_replay_upload(process): + return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400) + raw_name = (file.filename or "").strip() + if not raw_name: + return JSONResponse({"error": "缺少文件名"}, status_code=400) + base = Path(raw_name).name + if base in {".", ".."} or "/" in raw_name or "\\" in raw_name: + return JSONResponse({"error": "非法文件名"}, status_code=400) + ext = Path(base).suffix.lower() + if ext not in _TIKTOK_REPLAY_EXT: + return JSONResponse({"error": f"不支持的扩展名(允许 {', '.join(sorted(_TIKTOK_REPLAY_EXT))})"}, status_code=400) + dest = tiktok_replay_dir(process) / base + try: + data = await file.read(MAX_TIKTOK_REPLAY_UPLOAD + 1) + except OSError as e: + return JSONResponse({"error": f"读取上传失败: {e}"}, status_code=500) + if len(data) > MAX_TIKTOK_REPLAY_UPLOAD: + return JSONResponse({"error": "文件过大(上限 2GB)"}, status_code=413) + try: + dest.write_bytes(data) + except OSError as e: + return JSONResponse({"error": f"写入失败: {e}"}, status_code=500) + abs_posix = str(dest.resolve()).replace("\\", "/") + return {"message": "上传成功", "name": base, "replay_url": f"replayfile:{abs_posix}"} + + +@app.post("/tiktok_replay/delete") +async def tiktok_replay_delete(body: TiktokReplayDeleteBody): + if not allows_tiktok_replay_upload(body.process): + return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400) + base = Path(body.filename).name + if not base or base in {".", ".."}: + return JSONResponse({"error": "非法文件名"}, status_code=400) + root = tiktok_replay_dir(body.process) + target = root / base + try: + resolved = target.resolve() + if resolved.parent != root.resolve(): + return JSONResponse({"error": "路径越界"}, status_code=400) + except OSError: + return JSONResponse({"error": "路径无效"}, status_code=400) + if not target.is_file(): + return JSONResponse({"error": "文件不存在"}, status_code=404) + try: + target.unlink() + except OSError as e: + return JSONResponse({"error": f"删除失败: {e}"}, status_code=500) + return {"message": "已删除"} + + def _not_found_hint(process: str) -> str: if process_backend.mode == "local": return ( diff --git a/youtube.py b/youtube.py index 8ffb119..1a54aae 100644 --- a/youtube.py +++ b/youtube.py @@ -82,17 +82,22 @@ def _live_pm2_name() -> str: def _youtube_pro_style_config_paths() -> tuple[str, str]: - """多频道 Pro:每 PM2 名对应独立 youtube.* / URL_config.*(与 web 控制台一致)。""" + """每 PM2 进程独立 youtube.* / URL_config.*(与 web.py managed_*_relpath 一致);无 PM2 名时回落旧版共享文件。""" + legacy_yt = f"{script_path}/config/youtube.ini" + legacy_url = f"{script_path}/config/URL_config.ini" pm2 = _live_pm2_name() - if pm2 and "__" in pm2: + if pm2: low = pm2.lower() if low.startswith("youtube") or low.startswith("douyin_youtube"): safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2) - return ( - f"{script_path}/config/youtube.{safe}.ini", - f"{script_path}/config/URL_config.{safe}.ini", - ) - return f"{script_path}/config/youtube.ini", f"{script_path}/config/URL_config.ini" + yp = f"{script_path}/config/youtube.{safe}.ini" + up = f"{script_path}/config/URL_config.{safe}.ini" + if os.path.isfile(yp) or os.path.isfile(up): + return yp, up + if os.path.isfile(legacy_yt) or os.path.isfile(legacy_url): + return legacy_yt, legacy_url + return yp, up + return legacy_yt, legacy_url config_file, url_config_file = _youtube_pro_style_config_paths()