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({
))}
+
+ {t("直播源", "Video source")}
+
+ setSourceModePersist(e.target.value as TiktokSourceMode)}
+ >
+ FFmpeg(抖音 / TikTok 等直播间 URL)
+ {t("录播素材(本地上传)", "VOD upload")}
+ {t("SRS(OBS 推流)", "SRS (OBS)")}
+
+ {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("上传视频", "Upload")}
+
+
+ void loadReplayList()}
+ className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-zinc-300"
+ >
+ {t("刷新列表", "Refresh")}
+
+
+
+
+ {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
+
+
+ void (async () => {
+ try {
+ await Promise.resolve(onReplayGoLive(row.replay_url, row.name));
+ } catch {
+ /* parent toasts */
+ }
+ })()
+ }
+ className="rounded-lg bg-emerald-500/20 px-2 py-1 text-[11px] text-emerald-100 ring-1 ring-emerald-500/30"
+ >
+ {t("开始直播", "Go live")}
+
+ onRunProcess("stop")}
+ className="rounded-lg bg-rose-500/15 px-2 py-1 text-[11px] text-rose-100 ring-1 ring-rose-500/25"
+ >
+ {t("停播", "Stop")}
+
+ void deleteReplayFile(row.name)}
+ className="inline-flex items-center gap-1 rounded-lg bg-zinc-700/40 px-2 py-1 text-[11px] text-zinc-200"
+ >
+
+ {t("删除", "Del")}
+
+
+
+ ))}
+
+ )}
+
+ ) : 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 ? (
+ onCopy(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 ? (
+ onCopy(quickLinks.srs_play_flv || quickLinks.srs_http)}
+ >
+
+
+ ) : null}
+
+
+
+
+
+ ) : 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()