's'
This commit is contained in:
42
install-oneclick.sh
Normal file
42
install-oneclick.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键安装入口:Ubuntu/Debian 系 + 常见 ARM/x86_64,再调用现有 install-all(hub + business)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[oneclick] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f /etc/os-release ]; then
|
||||||
|
log "未找到 /etc/os-release,仅支持 Debian/Ubuntu 系。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
ID_LIKE="${ID_LIKE:-}"
|
||||||
|
if ! echo "${ID:-} ${ID_LIKE}" | grep -qiE 'debian|ubuntu'; then
|
||||||
|
log "当前发行版: ${PRETTY_NAME:-unknown}"
|
||||||
|
log "脚本仅在 Debian/Ubuntu 及其衍生版上测试;如需其它系统请直接阅读 scripts/linux/*.sh 手工执行。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH="$(uname -m 2>/dev/null || true)"
|
||||||
|
case "${ARCH}" in
|
||||||
|
x86_64 | amd64 | aarch64 | arm64 | armv7l | armv6l) ;;
|
||||||
|
*)
|
||||||
|
log "未识别的机器架构: ${ARCH:-?},将继续尝试安装(请自行确认依赖是否可用)。"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log "ROOT_DIR=${ROOT_DIR}"
|
||||||
|
log "架构=${ARCH} 系统=${PRETTY_NAME:-$ID}"
|
||||||
|
|
||||||
|
if [ ! -x "${ROOT_DIR}/install-all.sh" ]; then
|
||||||
|
log "缺少可执行的 install-all.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec bash "${ROOT_DIR}/install-all.sh" "$@"
|
||||||
@@ -308,6 +308,42 @@ class WebProcessBackend:
|
|||||||
if self._use_pm2 is None:
|
if self._use_pm2 is None:
|
||||||
await self.refresh_mode()
|
await self.refresh_mode()
|
||||||
|
|
||||||
|
async def online_process_names(self) -> list[str]:
|
||||||
|
"""当前在线的 PM2 / 本地注册进程名(用于 TikTok·OBS 等 HDMI 链路互斥)。"""
|
||||||
|
await self.ensure_mode()
|
||||||
|
if not self._use_pm2:
|
||||||
|
data = self._local._read_state()
|
||||||
|
names: list[str] = []
|
||||||
|
for name, row in (data.get("processes") or {}).items():
|
||||||
|
try:
|
||||||
|
pid = int((row or {}).get("pid") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if self._local._pid_alive(pid):
|
||||||
|
names.append(str(name))
|
||||||
|
return sorted(names)
|
||||||
|
code, text = await _shell_out("pm2 jlist", timeout=20.0)
|
||||||
|
if code != 0 or not text.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
items = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
name = it.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
env = it.get("pm2_env") if isinstance(it.get("pm2_env"), dict) else {}
|
||||||
|
status = str(env.get("status") or "").lower()
|
||||||
|
if status in ("online", "launching"):
|
||||||
|
out.append(str(name))
|
||||||
|
return out
|
||||||
|
|
||||||
async def start(self, process: str, script: str) -> str:
|
async def start(self, process: str, script: str) -> str:
|
||||||
await self.ensure_mode()
|
await self.ensure_mode()
|
||||||
if self._use_pm2:
|
if self._use_pm2:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CirclePlay,
|
CirclePlay,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
FolderOpen,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Languages,
|
Languages,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -30,6 +31,7 @@ import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
|
|||||||
import OverviewCharts from "@/components/OverviewCharts";
|
import OverviewCharts from "@/components/OverviewCharts";
|
||||||
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
|
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
|
||||||
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
|
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
|
||||||
|
import { LiveFilebrowserPanel } from "@/components/live-product/LiveFilebrowserPanel";
|
||||||
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
|
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
|
||||||
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||||||
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
||||||
@@ -40,6 +42,7 @@ type PortalTheme = "dark" | "light";
|
|||||||
type ViewKey =
|
type ViewKey =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "services"
|
| "services"
|
||||||
|
| "files"
|
||||||
| "live_youtube"
|
| "live_youtube"
|
||||||
| "live_tiktok"
|
| "live_tiktok"
|
||||||
| "android"
|
| "android"
|
||||||
@@ -400,6 +403,7 @@ export default function LiveControlApp() {
|
|||||||
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
|
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
|
||||||
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
|
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
|
||||||
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
|
{ 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_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
|
||||||
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
|
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
|
||||||
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
|
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
|
||||||
@@ -617,6 +621,32 @@ export default function LiveControlApp() {
|
|||||||
{dashErr}
|
{dashErr}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.02 }}
|
||||||
|
className={`rounded-2xl border p-2 backdrop-blur-xl ${
|
||||||
|
portalTheme === "light"
|
||||||
|
? "border-slate-200/90 bg-white/80 shadow-sm shadow-slate-200/30"
|
||||||
|
: "border-white/[0.07] bg-zinc-950/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<OverviewCharts snapshot={dash?.snapshot ?? null} theme={portalTheme} tr={t} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
className={`rounded-2xl border p-3 shadow-lg ${
|
||||||
|
portalTheme === "light"
|
||||||
|
? "border-fuchsia-200/80 bg-gradient-to-br from-fuchsia-50/90 via-white to-cyan-50/70 shadow-fuchsia-100/40"
|
||||||
|
: "border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 shadow-fuchsia-900/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LiveStudioScene3D tr={t} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -667,23 +697,6 @@ export default function LiveControlApp() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.05 }}
|
|
||||||
className="rounded-2xl border border-white/[0.07] bg-zinc-950/30 p-2 backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<OverviewCharts snapshot={dash?.snapshot ?? null} theme="dark" tr={t} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.09 }}
|
|
||||||
className="rounded-2xl border border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 p-3 shadow-lg shadow-fuchsia-900/10"
|
|
||||||
>
|
|
||||||
<LiveStudioScene3D tr={t} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{dash?.hardware && typeof dash.hardware === "object" ? (
|
{dash?.hardware && typeof dash.hardware === "object" ? (
|
||||||
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
|
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
|
||||||
<h3 className="text-base font-semibold text-white">
|
<h3 className="text-base font-semibold text-white">
|
||||||
@@ -1074,6 +1087,14 @@ export default function LiveControlApp() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{view === "files" ? (
|
||||||
|
<LiveFilebrowserPanel
|
||||||
|
t={t}
|
||||||
|
portalTheme={portalTheme}
|
||||||
|
filebrowserUrl={ql.filebrowser || ""}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{view === "services" ? (
|
{view === "services" ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{(dash?.services || []).map((s, i) => (
|
{(dash?.services || []).map((s, i) => (
|
||||||
|
|||||||
124
web-console/components/live-product/LiveFilebrowserPanel.tsx
Normal file
124
web-console/components/live-product/LiveFilebrowserPanel.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink, FolderOpen } from "lucide-react";
|
||||||
|
|
||||||
|
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||||
|
|
||||||
|
type TFn = (zh: string, en: string) => string;
|
||||||
|
type PortalTheme = "dark" | "light";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TFn;
|
||||||
|
portalTheme: PortalTheme;
|
||||||
|
filebrowserUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LiveFilebrowserPanel({ t, portalTheme, filebrowserUrl }: Props) {
|
||||||
|
const url = filebrowserUrl ? normalizeBrowserReachableHttpUrl(filebrowserUrl) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border p-6 shadow-xl ${
|
||||||
|
portalTheme === "light"
|
||||||
|
? "border-slate-200/90 bg-gradient-to-br from-amber-50/90 via-white to-slate-50"
|
||||||
|
: "border-amber-500/25 bg-gradient-to-br from-amber-500/[0.12] via-zinc-950/80 to-violet-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500/35 to-violet-600/25 ring-1 ring-white/10">
|
||||||
|
<FolderOpen className="h-5 w-5 text-amber-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className={`text-lg font-semibold tracking-tight ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{t("文件(File Browser)", "Files (File Browser)")}
|
||||||
|
</h2>
|
||||||
|
<p className={`mt-1 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-500"}`}>
|
||||||
|
{t(
|
||||||
|
"与 Docker 部署的 File Browser 分离:此处仅嵌入面板并说明 API,便于你自建「选本地视频 → 配合 TikTok HDMI 测试」等前端逻辑。",
|
||||||
|
"Separate from File Browser itself: embed + API notes so you can build “pick local video → TikTok HDMI test” flows.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border p-5 ${
|
||||||
|
portalTheme === "light" ? "border-slate-200/90 bg-white/90" : "border-white/[0.08] bg-zinc-950/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm font-medium ${portalTheme === "light" ? "text-slate-800" : "text-white"}`}>
|
||||||
|
{t("内置 UI(iframe)", "Built-in UI (iframe)")}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-500"}`}>
|
||||||
|
{t(
|
||||||
|
"若服务未启动或端口不通,请先在「服务」页启动 filebrowser 容器。",
|
||||||
|
"Start the filebrowser service from Services if the frame is blank.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{url ? (
|
||||||
|
<div
|
||||||
|
className={`mt-4 overflow-hidden rounded-xl border ${
|
||||||
|
portalTheme === "light" ? "border-slate-200 bg-slate-50" : "border-white/10 bg-black/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-white/5 px-3 py-2">
|
||||||
|
<span className="truncate font-mono text-[11px] text-zinc-500">{url}</span>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium ${
|
||||||
|
portalTheme === "light"
|
||||||
|
? "bg-violet-100 text-violet-800"
|
||||||
|
: "bg-violet-500/20 text-violet-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{t("新窗口", "New tab")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<iframe title="File Browser" src={url} className="h-[min(72vh,40rem)] w-full bg-black/20" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={`mt-4 text-sm ${portalTheme === "light" ? "text-amber-800" : "text-amber-200/90"}`}>
|
||||||
|
{t("未从总览获取到 filebrowser 链接;请确认 stack 配置与 hub 仪表盘。", "No filebrowser URL from dashboard.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border p-5 ${
|
||||||
|
portalTheme === "light" ? "border-slate-200/90 bg-white/90" : "border-white/[0.08] bg-zinc-950/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
|
||||||
|
{t("REST API(自定义前端)", "REST API (custom UI)")}
|
||||||
|
</h3>
|
||||||
|
<ul className={`mt-3 space-y-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
|
||||||
|
<li>
|
||||||
|
{t(
|
||||||
|
"登录:POST /api/login,Body 为 JSON:username / password(与 File Browser 账户一致),返回 Set-Cookie 或 token(视版本而定)。",
|
||||||
|
"Login: POST /api/login with JSON username/password; follow cookie/token per your File Browser version.",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{t(
|
||||||
|
"列目录:GET /api/resources(常需携带 Cookie)。具体字段以你部署的版本文档为准。",
|
||||||
|
"List: GET /api/resources (often cookie-authenticated). See your image version docs.",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{t(
|
||||||
|
"与 TikTok HDMI 页配合:在自定义脚本里拉取文件路径后,可写独立 ffmpeg 推流;控制台会在启动 TikTok / obs*.sh 时自动互斥其它 HDMI 链路进程。",
|
||||||
|
"With TikTok HDMI: your script can feed ffmpeg paths; the hub stops other TikTok/OBS HDMI sinks when you start one.",
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export type PortalNavTarget = "network" | "services" | "live_youtube" | "live_tiktok" | "android";
|
export type PortalNavTarget =
|
||||||
|
| "network"
|
||||||
|
| "services"
|
||||||
|
| "live_youtube"
|
||||||
|
| "live_tiktok"
|
||||||
|
| "android"
|
||||||
|
| "files";
|
||||||
|
|
||||||
type TFn = (zh: string, en: string) => string;
|
type TFn = (zh: string, en: string) => string;
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ export function LiveTiktokHdmiView({
|
|||||||
gradient: "from-cyan-400 to-blue-500",
|
gradient: "from-cyan-400 to-blue-500",
|
||||||
target: "live_tiktok",
|
target: "live_tiktok",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "File Browser",
|
||||||
|
sub: t("本地文件", "Local files"),
|
||||||
|
gradient: "from-amber-400 to-orange-500",
|
||||||
|
target: "files",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "HDMI",
|
label: "HDMI",
|
||||||
sub: t("板载输出", "SoC out"),
|
sub: t("板载输出", "SoC out"),
|
||||||
@@ -239,6 +245,15 @@ export function LiveTiktokHdmiView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/[0.06] p-4 ring-1 ring-white/[0.04]">
|
||||||
|
<p className="text-xs leading-relaxed text-cyan-100/90">
|
||||||
|
{t(
|
||||||
|
"HDMI 互斥:后端在「开始 / 重新开始」TikTok 或 obs*.sh(SRS 拉流上屏)时,会自动停止其它正在占用 HDMI 链路的同类进程;与 YouTube 推流互不干扰。",
|
||||||
|
"HDMI mutex: starting TikTok or obs*.sh stops other TikTok/OBS HDMI sinks; YouTube RTMP is unaffected.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||||
<div className="flex items-center gap-2 text-amber-200/90">
|
<div className="flex items-center gap-2 text-amber-200/90">
|
||||||
<Radio className="h-4 w-4" />
|
<Radio className="h-4 w-4" />
|
||||||
|
|||||||
@@ -259,22 +259,6 @@ export function LiveYoutubeUnmannedView({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setModePersist = (m: "single" | "pro") => {
|
|
||||||
setMode(m);
|
|
||||||
if (m === "pro") {
|
|
||||||
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
|
|
||||||
if (ch) {
|
|
||||||
const u = (ch.urlLines ?? "").trim();
|
|
||||||
setProUrlDraft(u || urlConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(MODE_KEY, m);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = loadYoutubeProChannels();
|
const list = loadYoutubeProChannels();
|
||||||
const initial =
|
const initial =
|
||||||
@@ -308,6 +292,52 @@ export function LiveYoutubeUnmannedView({
|
|||||||
|
|
||||||
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
||||||
|
|
||||||
|
const setModePersist = useCallback(
|
||||||
|
(m: "single" | "pro") => {
|
||||||
|
if (m === "single" && mode === "pro" && active) {
|
||||||
|
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
|
||||||
|
}
|
||||||
|
if (m === "pro" && channels.length) {
|
||||||
|
const first = channels[0];
|
||||||
|
const youtubePm2Ok = ytEntries.some((e) => e.pm2 === currentProc);
|
||||||
|
const pm2Name =
|
||||||
|
youtubePm2Ok && currentProc.trim()
|
||||||
|
? currentProc.trim()
|
||||||
|
: (first.pm2Name ?? "").trim() || defaultPm2NameForChannel(first.id);
|
||||||
|
const next: YoutubeProChannel[] = [
|
||||||
|
{
|
||||||
|
...first,
|
||||||
|
streamKey: ytFields.key.trim() || first.streamKey || "",
|
||||||
|
urlLines: urlConfig.trim() || first.urlLines || "",
|
||||||
|
pm2Name,
|
||||||
|
},
|
||||||
|
...channels.slice(1),
|
||||||
|
];
|
||||||
|
persistChannels(next);
|
||||||
|
const ch = next.find((c) => c.id === activeCh) ?? next[0];
|
||||||
|
setProUrlDraft((ch?.urlLines ?? "").trim() || urlConfig.trim());
|
||||||
|
}
|
||||||
|
setMode(m);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(MODE_KEY, m);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mode,
|
||||||
|
active,
|
||||||
|
channels,
|
||||||
|
proUrlDraft,
|
||||||
|
ytFields.key,
|
||||||
|
urlConfig,
|
||||||
|
currentProc,
|
||||||
|
ytEntries,
|
||||||
|
activeCh,
|
||||||
|
persistChannels,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const selectProChannel = useCallback(
|
const selectProChannel = useCallback(
|
||||||
(c: YoutubeProChannel) => {
|
(c: YoutubeProChannel) => {
|
||||||
if (active && mode === "pro") {
|
if (active && mode === "pro") {
|
||||||
|
|||||||
49
web.py
49
web.py
@@ -148,6 +148,43 @@ def is_tiktok_script(script: str) -> bool:
|
|||||||
return script.endswith(".py") and _stem(script).startswith("tiktok")
|
return script.endswith(".py") and _stem(script).startswith("tiktok")
|
||||||
|
|
||||||
|
|
||||||
|
def _script_targets_hdmi_sink(script: str) -> bool:
|
||||||
|
"""与采集卡/板端 HDMI 输出争用同一物理链路的进程(互斥;与 YouTube RTMP 推流无关)。"""
|
||||||
|
if is_tiktok_script(script):
|
||||||
|
return True
|
||||||
|
s = _stem(script).lower()
|
||||||
|
return script.endswith(".sh") and s.startswith("obs")
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]:
|
||||||
|
sc_keep = script_for_pm2_or_pro(keep_pm2)
|
||||||
|
if not sc_keep or not _script_targets_hdmi_sink(sc_keep):
|
||||||
|
return []
|
||||||
|
await process_backend.ensure_mode()
|
||||||
|
stopped: list[str] = []
|
||||||
|
try:
|
||||||
|
online = await process_backend.online_process_names()
|
||||||
|
except Exception:
|
||||||
|
online = []
|
||||||
|
for name in online:
|
||||||
|
if name == keep_pm2:
|
||||||
|
continue
|
||||||
|
sc = script_for_pm2_or_pro(name)
|
||||||
|
if not sc or not _script_targets_hdmi_sink(sc):
|
||||||
|
continue
|
||||||
|
info = await get_process_info(name)
|
||||||
|
st = (info.get("process_status") or "").lower()
|
||||||
|
if st not in ("online", "running"):
|
||||||
|
continue
|
||||||
|
await process_backend.stop(name)
|
||||||
|
if not str(name).startswith("web"):
|
||||||
|
await force_kill_ffmpeg(str(name), BASE_DIR)
|
||||||
|
stopped.append(str(name))
|
||||||
|
if stopped:
|
||||||
|
await asyncio.sleep(0.85)
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
|
||||||
def _build_process_monitor():
|
def _build_process_monitor():
|
||||||
return tuple(
|
return tuple(
|
||||||
{
|
{
|
||||||
@@ -645,8 +682,12 @@ async def start(process: str = Query(..., description="进程名")):
|
|||||||
sc = script_for_pm2_or_pro(process)
|
sc = script_for_pm2_or_pro(process)
|
||||||
if not sc:
|
if not sc:
|
||||||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||||||
|
stopped = await _stop_other_hdmi_sink_processes(process)
|
||||||
output = await process_backend.start(process, sc)
|
output = await process_backend.start(process, sc)
|
||||||
return JSONResponse({"output": output})
|
prefix = ""
|
||||||
|
if stopped:
|
||||||
|
prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
|
||||||
|
return JSONResponse({"output": prefix + output})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/start")
|
@app.post("/start")
|
||||||
@@ -689,10 +730,14 @@ async def restart(process: str = Query(..., description="进程名")):
|
|||||||
sc = script_for_pm2_or_pro(process)
|
sc = script_for_pm2_or_pro(process)
|
||||||
if not sc:
|
if not sc:
|
||||||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||||||
|
stopped = await _stop_other_hdmi_sink_processes(process)
|
||||||
output = await process_backend.restart(process, sc)
|
output = await process_backend.restart(process, sc)
|
||||||
if not process.startswith("web"):
|
if not process.startswith("web"):
|
||||||
await force_kill_ffmpeg(process, BASE_DIR)
|
await force_kill_ffmpeg(process, BASE_DIR)
|
||||||
return JSONResponse({"output": output})
|
prefix = ""
|
||||||
|
if stopped:
|
||||||
|
prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
|
||||||
|
return JSONResponse({"output": prefix + output})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/restart")
|
@app.post("/restart")
|
||||||
|
|||||||
Reference in New Issue
Block a user