Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
2026-03-28 15:08:59 -05:00

627 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
buildYoutubeIniFromFields,
extractDouyinLiveUrls,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
} from "@/lib/youtubeConfigUtils";
import {
defaultPm2NameForChannel,
loadYoutubeProChannels,
newChannelId,
saveYoutubeProChannels,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
function isYoutubeProcess(e: ProcessEntry) {
return /youtube/i.test(e.script) || /youtube/i.test(e.pm2);
}
const MODE_KEY = "live-hub-youtube-mode-v1";
type Props = {
t: TFn;
busy: boolean;
entries: ProcessEntry[];
currentProc: string;
setCurrentProc: (v: string) => void;
urlConfig: string;
setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
};
export function LiveYoutubeUnmannedView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
douyinUrl,
setDouyinUrl,
onRunProcess,
onSaveUrlConfig,
onSaveBusinessMeta,
fetchJson,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const processOptions = ytEntries.length ? ytEntries : entries;
const [mode, setMode] = useState<"single" | "pro">("single");
const [channels, setChannels] = useState<YoutubeProChannel[]>([]);
const [activeCh, setActiveCh] = useState<string>("");
const [youtubeIniText, setYoutubeIniText] = useState("");
const [ytFields, setYtFields] = useState<YoutubeIniFields>({
key: "",
rtmps: true,
bitrate: "",
fastAudio: false,
});
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
const [ytIniLoading, setYtIniLoading] = useState(false);
const [proUrlDraft, setProUrlDraft] = useState("");
useEffect(() => {
try {
const m = window.localStorage.getItem(MODE_KEY);
if (m === "pro" || m === "single") setMode(m);
} catch {
/* ignore */
}
}, []);
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(() => {
const list = loadYoutubeProChannels();
const initial =
list.length > 0
? list
: (() => {
const id = newChannelId();
return [
{
id,
name: "线路 1",
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
},
];
})();
setChannels(initial);
if (!list.length) saveYoutubeProChannels(initial);
}, []);
useEffect(() => {
if (channels.length && !activeCh) setActiveCh(channels[0].id);
}, [channels, activeCh]);
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
}, []);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
setYtIniLoading(true);
setYtIniStatus(null);
try {
const data = await fetchJson<{ content?: string }>(
`/get_config?process=${encodeURIComponent(currentProc)}`,
);
const text = data.content ?? "";
setYoutubeIniText(text);
setYtFields(parseYoutubeIniFields(text));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
}, [currentProc, fetchJson]);
useEffect(() => {
if (!currentProc) return;
void loadYoutubeIni();
}, [currentProc, loadYoutubeIni]);
const updateActive = (patch: Partial<YoutubeProChannel>) => {
if (!active) return;
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
};
const saveYoutubeIniToServer = async (content: string) => {
if (!currentProc) {
setYtIniStatus(t("请选择进程", "Pick a process"));
return;
}
setYtIniLoading(true);
setYtIniStatus(null);
try {
await fetchJson("/save_config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
setYoutubeIniText(content);
setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved"));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
};
const applyFieldsAndSave = async () => {
const body = buildYoutubeIniFromFields(ytFields);
await saveYoutubeIniToServer(body);
};
const syncFieldsFromText = () => {
setYtFields(parseYoutubeIniFields(youtubeIniText));
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
};
const keySuffixUi = useMemo(() => {
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
const k = active?.streamKey ?? "";
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey]);
const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
setUrlConfig(proUrlDraft);
onSaveUrlConfig(proUrlDraft);
};
const pushProStreamKeyToServer = async () => {
if (!active) return;
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
const f: YoutubeIniFields = {
key: (active.streamKey ?? "").trim(),
rtmps: base.rtmps,
bitrate: base.bitrate,
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
await saveYoutubeIniToServer(body);
updateActive({ streamKey: f.key });
};
const lock = busy || ytIniLoading;
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-red-500/30 to-violet-600/30 ring-1 ring-white/10">
<MonitorPlay className="h-5 w-5 text-red-200" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight text-white">
{t("YouTube 无人直播", "YouTube unmanned live")}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{t(
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
"Desktop-style live control: single or Pro lanes, no account login.",
)}
</p>
</div>
</div>
<div className="flex rounded-xl border border-white/10 bg-black/30 p-1">
<button
type="button"
onClick={() => setModePersist("single")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "single" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("单频道", "Single")}
</button>
<button
type="button"
onClick={() => setModePersist("pro")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "pro" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("多频道 Pro", "Multi Pro")}
</button>
</div>
</div>
</div>
<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">
<Radio className="h-4 w-4" />
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
</div>
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
{t("进程PM2", "PM2 process")}
</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={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{processOptions.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
{mode === "pro" && active ? (
<p className="mt-2 font-mono text-[11px] text-cyan-300/80">
Pro {t("建议独立进程名", "suggested")}: {active.pm2Name || defaultPm2NameForChannel(active.id)}
</p>
) : null}
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{(
[
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
] as const
).map(([a, label, cls]) => (
<button
key={a}
type="button"
disabled={lock}
onClick={() => onRunProcess(a)}
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
>
{label}
</button>
))}
</div>
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
key {t("尾码(参考)", "suffix")}: {keySuffixUi || "—"}
</p>
</div>
{mode === "single" ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("编辑 config/youtube.ini与桌面端一致字段。不使用 Google 登录。", "Edits config/youtube.ini. No Google OAuth.")}
</p>
<div className="mt-4 space-y-3">
<label className="block text-xs text-zinc-400">{t("串流密钥", "Stream key")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm font-mono text-zinc-200"
spellCheck={false}
autoComplete="off"
value={ytFields.key}
onChange={(e) => setYtFields((f) => ({ ...f, key: e.target.value }))}
placeholder="xxxx-xxxx-xxxx-xxxx-xxxx"
/>
<div className="grid gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-xs text-zinc-400">
RTMPS
<select
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
value={ytFields.rtmps ? "1" : "0"}
onChange={(e) => setYtFields((f) => ({ ...f, rtmps: e.target.value === "1" }))}
>
<option value="1">{t("是(默认)", "Yes")}</option>
<option value="0">{t("否 → RTMP", "No")}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-zinc-400">
{t("比特率 kbps", "Bitrate kbps")}
<input
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
value={ytFields.bitrate}
onChange={(e) => setYtFields((f) => ({ ...f, bitrate: e.target.value }))}
placeholder="4000"
/>
</label>
<label className="flex cursor-pointer items-center gap-2 pt-6 text-xs text-zinc-300">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={ytFields.fastAudio}
onChange={(e) => setYtFields((f) => ({ ...f, fastAudio: e.target.checked }))}
/>
fast_audio{t("轻量音频", "lighter audio")}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void loadYoutubeIni()}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-200"
>
<RefreshCw className={`h-3.5 w-3.5 ${ytIniLoading ? "animate-spin" : ""}`} />
{t("重新读取", "Reload")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void applyFieldsAndSave()}
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t("保存 youtube.ini", "Save youtube.ini")}
</button>
<button
type="button"
disabled={lock}
onClick={syncFieldsFromText}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-400"
>
{t("从原文同步表单", "Parse raw")}
</button>
</div>
<details className="mt-4 rounded-xl border border-white/5 bg-black/20 p-3">
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级youtube.ini 原文", "Raw youtube.ini")}</summary>
<textarea
className="mt-2 h-36 w-full resize-y rounded-lg border border-white/10 bg-black/50 p-3 font-mono text-[11px] text-zinc-400"
value={youtubeIniText}
onChange={(e) => setYoutubeIniText(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
className="mt-2 rounded-lg bg-cyan-500/15 px-3 py-1.5 text-xs text-cyan-200"
onClick={() => void saveYoutubeIniToServer(youtubeIniText)}
>
{t("保存原文", "Save raw")}
</button>
</details>
{ytIniStatus ? <p className="mt-2 font-mono text-xs text-zinc-500">{ytIniStatus}</p> : null}
</div>
) : (
<div className="space-y-6">
<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("Pro 线路列表", "Pro channels")}</h3>
<button
type="button"
disabled={lock}
className="inline-flex items-center gap-1 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-500/30"
onClick={() => {
const id = newChannelId();
const nc: YoutubeProChannel = {
id,
name: `${t("线路", "Ch")} ${channels.length + 1}`,
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
};
persistChannels([...channels, nc]);
setActiveCh(id);
setProUrlDraft("");
}}
>
<Plus className="h-3.5 w-3.5" />
{t("新增线路", "Add")}
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{channels.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
if (active && mode === "pro") {
persistChannels(
channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)),
);
}
const u = (c.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
setActiveCh(c.id);
}}
className={`rounded-xl border px-3 py-2 text-left text-xs ${
activeCh === c.id ? "border-violet-500/50 bg-violet-500/10 text-white" : "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{c.name}</div>
<div className="font-mono text-[10px] text-zinc-600">{c.pm2Name || defaultPm2NameForChannel(c.id)}</div>
</button>
))}
</div>
{active ? (
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
value={active.name}
onChange={(e) => updateActive({ name: e.target.value })}
placeholder={t("线路显示名", "Channel name")}
/>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => updateActive({ pm2Name: e.target.value })}
placeholder="youtube2__…"
/>
<label className="block text-xs text-zinc-500">{t("串流密钥(本线路)", "Stream key for this lane")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
spellCheck={false}
value={active.streamKey ?? ""}
onChange={(e) => updateActive({ streamKey: e.target.value })}
placeholder="key / rtmp(s) URL"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void pushProStreamKeyToServer()}
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
>
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")}
</button>
</div>
<p className="text-[11px] text-amber-200/70">
{t(
"服务器仅一份 youtube.ini推送会覆盖多路并行请在板上为每路配置独立 PM2 与独立 config 目录,或轮流推送。",
"Server has one youtube.ini; overwrite. For true parallel lanes use separate PM2 + config dirs on the host.",
)}
</p>
<textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={active.notes ?? ""}
onChange={(e) => updateActive({ notes: e.target.value })}
placeholder={t("备注", "Notes")}
/>
<button
type="button"
disabled={lock || channels.length < 2}
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
onClick={() => {
const next = channels.filter((c) => c.id !== active.id);
persistChannels(next);
setActiveCh(next[0]?.id || "");
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("删除线路", "Remove")}
</button>
</div>
) : null}
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
<textarea
className="mt-4 min-h-[160px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={proUrlDraft}
onChange={(e) => setProUrlDraft(e.target.value)}
spellCheck={false}
placeholder="https://live.douyin.com/..."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => {
setProUrlDraft(urlConfig);
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
}}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
>
{t("与全局 URL 同步", "Sync from global")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void saveProUrlAndServer()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存到服务器并记入本线路", "Save to server + lane")}
</button>
</div>
</div>
</div>
)}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (metadata)")}</h3>
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON供运维脚本读取。", "business.json for automation.")}</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<button
type="button"
disabled={lock}
onClick={() => void onSaveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存到配置中心", "Save to hub")}
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{mode === "pro"
? t("单频道模式在此编辑全局文件Pro 线路请用上方「当前线路」块保存。", "Pro lanes use the lane editor above.")
: t("一行一个直播间地址。", "One URL per line.")}
</p>
{mode === "single" ? (
<>
<textarea
className="mt-4 min-h-[200px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
onClick={() => void onSaveUrlConfig()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</>
) : (
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
{douyinUrls.length > 0 ? (
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
{douyinUrls.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
}