's'
This commit is contained in:
@@ -275,6 +275,13 @@ export function LiveAndroidWorkstation({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||||
{(d.state || "").toLowerCase() !== "device" ? (
|
||||
<div className="mt-1 text-[10px] font-medium text-amber-200/90">
|
||||
{d.state === "unauthorized"
|
||||
? t("未授权:请在手机上点「允许 USB 调试」", "Unauthorized — allow USB debugging on device")
|
||||
: t(`状态: ${d.state}`, `State: ${d.state}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === "files" ? (
|
||||
<LiveFilebrowserPanel
|
||||
t={t}
|
||||
portalTheme={portalTheme}
|
||||
filebrowserUrl={ql.filebrowser || ""}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "services" ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(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}
|
||||
|
||||
@@ -7,8 +7,7 @@ export type PortalNavTarget =
|
||||
| "services"
|
||||
| "live_youtube"
|
||||
| "live_tiktok"
|
||||
| "android"
|
||||
| "files";
|
||||
| "android";
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
/** 录播:写入 URL 配置为单条 replayfile 行并启动进程 */
|
||||
onReplayGoLive: (replayUrl: string, displayLabel: string) => void | Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
apiPrefix: string;
|
||||
quickLinks: Record<string, string>;
|
||||
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<TiktokSourceMode>("ffmpeg");
|
||||
const [replayFiles, setReplayFiles] = useState<ReplayFileRow[]>([]);
|
||||
const [replayLoading, setReplayLoading] = useState(false);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>("1080p30");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>("1080p30");
|
||||
const [urlReloading, setUrlReloading] = useState(false);
|
||||
@@ -87,6 +105,27 @@ export function LiveTiktokHdmiView({
|
||||
const [hdmiNotes, setHdmiNotes] = useState("");
|
||||
const [checks, setChecks] = useState<Record<string, boolean>>({});
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("直播源", "Video source")}
|
||||
</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={sourceMode}
|
||||
onChange={(e) => setSourceModePersist(e.target.value as TiktokSourceMode)}
|
||||
>
|
||||
<option value="ffmpeg">FFmpeg(抖音 / TikTok 等直播间 URL)</option>
|
||||
<option value="replay">{t("录播素材(本地上传)", "VOD upload")}</option>
|
||||
<option value="srs">{t("SRS(OBS 推流)", "SRS (OBS)")}</option>
|
||||
</select>
|
||||
{sourceMode === "srs" ? (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-amber-200/85">
|
||||
{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.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
@@ -287,6 +413,147 @@ export function LiveTiktokHdmiView({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sourceMode === "replay" ? (
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Film className="h-4 w-4 text-violet-300" />
|
||||
{t("录播素材", "VOD library")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-violet-500/15 px-3 py-2 text-xs font-medium text-violet-100 ring-1 ring-violet-500/30">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span>{t("上传视频", "Upload")}</span>
|
||||
<input type="file" accept=".mp4,.mkv,.ts,.flv,.mov,.m4v,.webm" className="hidden" onChange={onPickUpload} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !currentProc}
|
||||
onClick={() => void loadReplayList()}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-zinc-300"
|
||||
>
|
||||
{t("刷新列表", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
{t(
|
||||
"文件保存在服务器 config/tiktok_replay/对应进程目录;点「开始直播」会写入 replayfile 行并启动 PM2 进程。停播请用上方「停止直播」。",
|
||||
"Files under config/tiktok_replay/<process>. Go live writes replayfile + starts PM2. Use Stop above to stop.",
|
||||
)}
|
||||
</p>
|
||||
{replayLoading ? (
|
||||
<p className="mt-4 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
|
||||
) : replayFiles.length === 0 ? (
|
||||
<p className="mt-4 text-xs text-zinc-500">{t("暂无文件,请先上传。", "No files yet.")}</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{replayFiles.map((row) => (
|
||||
<li
|
||||
key={row.name}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-300"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{row.name}</span>
|
||||
<span className="shrink-0 text-zinc-500">{(row.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() =>
|
||||
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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => 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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("删除", "Del")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sourceMode === "srs" ? (
|
||||
<div className="rounded-2xl border border-emerald-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<h3 className="text-sm font-semibold text-white">{t("SRS / OBS 推流", "SRS / OBS ingest")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"OBS:设置 → 推流 → 服务选「自定义」,服务器填 RTMP 地址;串流密钥可与 SRS 应用配置一致(默认 live/livestream)。",
|
||||
"OBS → Stream → Custom: RTMP URL as server; stream key per SRS app (default live/livestream).",
|
||||
)}
|
||||
</p>
|
||||
<dl className="mt-4 space-y-3 text-xs">
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("RTMP 推流地址(OBS 服务器)", "RTMP publish URL")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-emerald-200/90">
|
||||
<span className="break-all">{quickLinks.srs_rtmp_publish || "—"}</span>
|
||||
{quickLinks.srs_rtmp_publish ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(quickLinks.srs_rtmp_publish)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("HTTP-FLV 预览", "HTTP-FLV preview")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-zinc-400">
|
||||
<span className="break-all">{quickLinks.srs_play_flv || quickLinks.srs_http || "—"}</span>
|
||||
{quickLinks.srs_play_flv || quickLinks.srs_http ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(quickLinks.srs_play_flv || quickLinks.srs_http)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("SRS 控制台", "SRS web UI")}</dt>
|
||||
<dd className="mt-1 font-mono text-zinc-400">
|
||||
<a
|
||||
href={quickLinks.srs_http || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-300 hover:underline"
|
||||
>
|
||||
{quickLinks.srs_http || "—"}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Monitor className="h-4 w-4 text-cyan-400" />
|
||||
@@ -348,6 +615,7 @@ export function LiveTiktokHdmiView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sourceMode === "ffmpeg" ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
|
||||
@@ -406,12 +674,13 @@ export function LiveTiktokHdmiView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LiveProcessLiveLogCard
|
||||
t={t}
|
||||
currentProc={currentProc}
|
||||
fetchJson={fetchJson}
|
||||
urlListText={urlConfig}
|
||||
urlListText={sourceMode === "ffmpeg" ? urlConfig : ""}
|
||||
keySuffixUi=""
|
||||
showKeyRow={false}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <T,>(path: string, init?: RequestInit) => Promise<T>,
|
||||
): Promise<YoutubeProChannel[]> {
|
||||
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: <T,>(path: string, init?: RequestInit) => Promise<T>,
|
||||
channels: YoutubeProChannel[],
|
||||
): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user