This commit is contained in:
eric
2026-03-28 17:34:28 -05:00
parent 46fb194a02
commit e050afbac1
8 changed files with 392 additions and 330 deletions

View File

@@ -2,19 +2,19 @@
from __future__ import annotations from __future__ import annotations
import httpx import httpx
from typing import Dict, Any from typing import Any, Dict, List, Optional, Union
from .. import utils from .. import utils
OptionalStr = str | None OptionalStr = Optional[str]
OptionalDict = Dict[str, Any] | None OptionalDict = Optional[Dict[str, Any]]
async def async_req( async def async_req(
url: str, url: str,
proxy_addr: OptionalStr = None, proxy_addr: OptionalStr = None,
headers: OptionalDict = None, headers: OptionalDict = None,
data: dict | bytes | None = None, data: Union[dict, bytes, None] = None,
json_data: dict | list | None = None, json_data: Union[dict, List, None] = None,
timeout: int = 20, timeout: int = 20,
redirect_url: bool = False, redirect_url: bool = False,
return_cookies: bool = False, return_cookies: bool = False,
@@ -23,7 +23,7 @@ async def async_req(
content_conding: str = 'utf-8', content_conding: str = 'utf-8',
verify: bool = False, verify: bool = False,
http2: bool = True http2: bool = True
) -> OptionalDict | OptionalStr | tuple: ) -> Union[OptionalDict, OptionalStr, tuple]:
if headers is None: if headers is None:
headers = {} headers = {}
try: try:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import gzip import gzip
from typing import List, Optional, Union
import urllib.parse import urllib.parse
import urllib.error import urllib.error
import requests import requests
@@ -15,16 +16,16 @@ opener = urllib.request.build_opener(no_proxy_handler)
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
OptionalStr = str | None OptionalStr = Optional[str]
OptionalDict = dict | None OptionalDict = Optional[dict]
def sync_req( def sync_req(
url: str, url: str,
proxy_addr: OptionalStr = None, proxy_addr: OptionalStr = None,
headers: OptionalDict = None, headers: OptionalDict = None,
data: dict | bytes | None = None, data: Union[dict, bytes, None] = None,
json_data: dict | list | None = None, json_data: Union[dict, List, None] = None,
timeout: int = 20, timeout: int = 20,
redirect_url: bool = False, redirect_url: bool = False,
abroad: bool = False, abroad: bool = False,

View File

@@ -19,7 +19,7 @@ import uuid
from operator import itemgetter from operator import itemgetter
import urllib.parse import urllib.parse
import urllib.error import urllib.error
from typing import List from typing import List, Optional, Union
import httpx import httpx
import ssl import ssl
import re import re
@@ -37,8 +37,8 @@ from .ab_sign import ab_sign
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
OptionalStr = str | None OptionalStr = Optional[str]
OptionalDict = dict | None OptionalDict = Optional[dict]
def get_params(url: str, params: str) -> OptionalStr: def get_params(url: str, params: str) -> OptionalStr:
@@ -285,7 +285,7 @@ async def get_douyin_stream_data(url: str, proxy_addr: OptionalStr = None, cooki
@trace_error_decorator @trace_error_decorator
async def get_tiktok_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict | None: async def get_tiktok_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> Optional[dict]:
headers = { headers = {
'referer': 'https://www.tiktok.com/', 'referer': 'https://www.tiktok.com/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
@@ -364,7 +364,7 @@ async def get_kuaishou_stream_data(url: str, proxy_addr: OptionalStr = None, coo
@trace_error_decorator @trace_error_decorator
async def get_kuaishou_stream_data2(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict | None: async def get_kuaishou_stream_data2(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> Optional[dict]:
headers = { headers = {
'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))', 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
@@ -964,7 +964,7 @@ async def get_sooplive_cdn_url(broad_no: str, proxy_addr: OptionalStr = None, co
@trace_error_decorator @trace_error_decorator
async def get_sooplive_tk(url: str, rtype: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> str | tuple: async def get_sooplive_tk(url: str, rtype: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> Union[str, tuple]:
headers = { headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
'Origin': 'https://play.sooplive.co.kr', 'Origin': 'https://play.sooplive.co.kr',
@@ -1557,7 +1557,7 @@ def get_looklive_secret_data(text) -> tuple:
charset = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?' charset = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?'
return ''.join(secrets.choice(charset) for _ in range(size)).encode('utf-8') return ''.join(secrets.choice(charset) for _ in range(size)).encode('utf-8')
def aes_encrypt(_text: str | bytes, _sec_key: str | bytes) -> bytes: def aes_encrypt(_text: Union[str, bytes], _sec_key: Union[str, bytes]) -> bytes:
if isinstance(_text, str): if isinstance(_text, str):
_text = _text.encode('utf-8') _text = _text.encode('utf-8')
if isinstance(_sec_key, str): if isinstance(_sec_key, str):
@@ -1570,7 +1570,7 @@ def get_looklive_secret_data(text) -> tuple:
encoded_ciphertext = base64.b64encode(ciphertext) encoded_ciphertext = base64.b64encode(ciphertext)
return encoded_ciphertext return encoded_ciphertext
def rsa_encrypt(_text: str | bytes, pub_key: str, mod: str) -> str: def rsa_encrypt(_text: Union[str, bytes], pub_key: str, mod: str) -> str:
if isinstance(_text, str): if isinstance(_text, str):
_text = _text.encode('utf-8') _text = _text.encode('utf-8')
text_reversed = _text[::-1] text_reversed = _text[::-1]
@@ -2256,7 +2256,7 @@ async def get_liveme_stream_url(url: str, proxy_addr: OptionalStr = None, cookie
return result return result
async def get_huajiao_sn(url: str, cookies: OptionalStr = None, proxy_addr: OptionalStr = None) -> tuple | None: async def get_huajiao_sn(url: str, cookies: OptionalStr = None, proxy_addr: OptionalStr = None) -> Optional[tuple]:
headers = { headers = {
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6', 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'referer': 'https://www.huajiao.com/', 'referer': 'https://www.huajiao.com/',

View File

@@ -20,6 +20,7 @@ import re
from operator import itemgetter from operator import itemgetter
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Union
from .utils import trace_error_decorator from .utils import trace_error_decorator
from .spider import ( from .spider import (
get_douyu_stream_data, get_bilibili_stream_data get_douyu_stream_data, get_bilibili_stream_data
@@ -412,7 +413,7 @@ async def get_netease_stream_url(json_data: dict, video_quality: str) -> dict:
async def get_stream_url(json_data: dict, video_quality: str, url_type: str = 'm3u8', spec: bool = False, async def get_stream_url(json_data: dict, video_quality: str, url_type: str = 'm3u8', spec: bool = False,
hls_extra_key: str | int = None, flv_extra_key: str | int = None) -> dict: hls_extra_key: Union[str, int, None] = None, flv_extra_key: Union[str, int, None] = None) -> dict:
if not json_data['is_live']: if not json_data['is_live']:
return json_data return json_data

View File

@@ -11,15 +11,15 @@ import functools
import hashlib import hashlib
import re import re
import traceback import traceback
from typing import Any from typing import Any, List, Optional, Union
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from collections import OrderedDict from collections import OrderedDict
import execjs import execjs
from .logger import logger from .logger import logger
import configparser import configparser
OptionalStr = str | None OptionalStr = Optional[str]
OptionalDict = dict | None OptionalDict = Optional[dict]
class Color: class Color:
@@ -53,7 +53,7 @@ def trace_error_decorator(func: callable) -> callable:
return wrapper return wrapper
def check_md5(file_path: str | Path) -> str: def check_md5(file_path: Union[str, Path]) -> str:
with open(file_path, 'rb') as fp: with open(file_path, 'rb') as fp:
file_md5 = hashlib.md5(fp.read()).hexdigest() file_md5 = hashlib.md5(fp.read()).hexdigest()
return file_md5 return file_md5
@@ -64,7 +64,7 @@ def dict_to_cookie_str(cookies_dict: dict) -> str:
return cookie_str return cookie_str
def read_config_value(file_path: str | Path, section: str, key: str) -> str | None: def read_config_value(file_path: Union[str, Path], section: str, key: str) -> Optional[str]:
config = configparser.ConfigParser() config = configparser.ConfigParser()
try: try:
@@ -84,7 +84,7 @@ def read_config_value(file_path: str | Path, section: str, key: str) -> str | No
return None return None
def update_config(file_path: str | Path, section: str, key: str, new_value: str) -> None: def update_config(file_path: Union[str, Path], section: str, key: str, new_value: str) -> None:
config = configparser.ConfigParser() config = configparser.ConfigParser()
try: try:
@@ -137,7 +137,7 @@ def remove_emojis(text: str, replace_text: str = '') -> str:
return emoji_pattern.sub(replace_text, text) return emoji_pattern.sub(replace_text, text)
def remove_duplicate_lines(file_path: str | Path) -> None: def remove_duplicate_lines(file_path: Union[str, Path]) -> None:
unique_lines = OrderedDict() unique_lines = OrderedDict()
text_encoding = 'utf-8-sig' text_encoding = 'utf-8-sig'
with open(file_path, 'r', encoding=text_encoding) as input_file: with open(file_path, 'r', encoding=text_encoding) as input_file:
@@ -148,7 +148,7 @@ def remove_duplicate_lines(file_path: str | Path) -> None:
output_file.write(line + '\n') output_file.write(line + '\n')
def check_disk_capacity(file_path: str | Path, show: bool = False) -> float: def check_disk_capacity(file_path: Union[str, Path], show: bool = False) -> float:
absolute_path = os.path.abspath(file_path) absolute_path = os.path.abspath(file_path)
directory = os.path.dirname(absolute_path) directory = os.path.dirname(absolute_path)
disk_usage = shutil.disk_usage(directory) disk_usage = shutil.disk_usage(directory)
@@ -188,7 +188,7 @@ def jsonp_to_json(jsonp_str: str) -> OptionalDict:
raise Exception("No JSON data found in JSONP response.") raise Exception("No JSON data found in JSONP response.")
def replace_url(file_path: str | Path, old: str, new: str) -> None: def replace_url(file_path: Union[str, Path], old: str, new: str) -> None:
with open(file_path, 'r', encoding='utf-8-sig') as f: with open(file_path, 'r', encoding='utf-8-sig') as f:
content = f.read() content = f.read()
if old in content: if old in content:
@@ -196,7 +196,7 @@ def replace_url(file_path: str | Path, old: str, new: str) -> None:
f.write(content.replace(old, new)) f.write(content.replace(old, new))
def get_query_params(url: str, param_name: OptionalStr) -> dict | list[str]: def get_query_params(url: str, param_name: OptionalStr) -> Union[dict, List[str]]:
parsed_url = urlparse(url) parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)

View File

@@ -175,9 +175,6 @@ export default function LiveControlApp() {
const [entries, setEntries] = useState<ProcessEntry[]>([]); const [entries, setEntries] = useState<ProcessEntry[]>([]);
const [currentProc, setCurrentProc] = useState(""); const [currentProc, setCurrentProc] = useState("");
const [urlConfig, setUrlConfig] = useState(""); const [urlConfig, setUrlConfig] = useState("");
const [douyinUrl, setDouyinUrl] = useState("");
const [businessYoutubeKey, setBusinessYoutubeKey] = useState("");
const [syncYoutubeIni, setSyncYoutubeIni] = useState(false);
const [apkPath, setApkPath] = useState("/sdcard/app.apk"); const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState(""); const [androidSerial, setAndroidSerial] = useState("");
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null); const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
@@ -267,22 +264,6 @@ export default function LiveControlApp() {
})(); })();
}, [currentProc, fetchJson, view]); }, [currentProc, fetchJson, view]);
useEffect(() => {
void (async () => {
try {
const b = await fetchJson<{
streams?: { douyin_input_url?: string; youtube_rtmp_key?: string };
}>("/hub/business/douyinyoutube");
const u = b.streams?.douyin_input_url;
if (u) setDouyinUrl(u);
const k = b.streams?.youtube_rtmp_key;
if (k !== undefined && k !== null) setBusinessYoutubeKey(k);
} catch {
/* ignore */
}
})();
}, [fetchJson]);
useEffect(() => { useEffect(() => {
if (view !== "settings") return; if (view !== "settings") return;
void (async () => { void (async () => {
@@ -331,15 +312,16 @@ export default function LiveControlApp() {
} }
}; };
const runProcess = async (action: "start" | "stop" | "restart") => { const runProcess = async (action: "start" | "stop" | "restart", processOverride?: string) => {
if (!currentProc) return; const proc = (processOverride ?? currentProc).trim();
if (!proc) return;
setBusy(`proc:${action}`); setBusy(`proc:${action}`);
try { try {
const path = `/${action}`; const path = `/${action}`;
const data = await fetchJson<{ output?: string; message?: string }>(path, { const data = await fetchJson<{ output?: string; message?: string }>(path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ process: currentProc }), body: JSON.stringify({ process: proc }),
}); });
notify(String(data.output || data.message || "OK")); notify(String(data.output || data.message || "OK"));
await refreshDashboard(); await refreshDashboard();
@@ -364,33 +346,7 @@ export default function LiveControlApp() {
notify(t("URL 配置已保存", "URL config saved")); notify(t("URL 配置已保存", "URL config saved"));
} catch (e) { } catch (e) {
notify((e as Error).message); notify((e as Error).message);
} finally { throw e;
setBusy(null);
}
};
const saveBusinessMeta = async () => {
setBusy("save:meta");
try {
const data = await fetchJson<{ message?: string }>("/hub/business/douyinyoutube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
streams: {
douyin_input_url: douyinUrl,
youtube_rtmp_key: businessYoutubeKey,
},
sync_youtube_ini: syncYoutubeIni,
}),
});
const detail = (data.message || "").trim();
notify(
detail.length > 0 && detail.length < 280
? detail
: t("业务元数据已同步", "Business metadata saved"),
);
} catch (e) {
notify((e as Error).message);
} finally { } finally {
setBusy(null); setBusy(null);
} }
@@ -1119,15 +1075,8 @@ export default function LiveControlApp() {
setCurrentProc={setCurrentProc} setCurrentProc={setCurrentProc}
urlConfig={urlConfig} urlConfig={urlConfig}
setUrlConfig={setUrlConfig} setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl} onRunProcess={(a, pm) => void runProcess(a, pm)}
setDouyinUrl={setDouyinUrl} onSaveUrlConfig={(c) => saveUrlConfig(c)}
businessYoutubeKey={businessYoutubeKey}
setBusinessYoutubeKey={setBusinessYoutubeKey}
syncYoutubeIni={syncYoutubeIni}
setSyncYoutubeIni={setSyncYoutubeIni}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
fetchJson={fetchJson} fetchJson={fetchJson}
/> />
) : null} ) : null}
@@ -1141,15 +1090,8 @@ export default function LiveControlApp() {
setCurrentProc={setCurrentProc} setCurrentProc={setCurrentProc}
urlConfig={urlConfig} urlConfig={urlConfig}
setUrlConfig={setUrlConfig} setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
businessYoutubeKey={businessYoutubeKey}
setBusinessYoutubeKey={setBusinessYoutubeKey}
syncYoutubeIni={syncYoutubeIni}
setSyncYoutubeIni={setSyncYoutubeIni}
onRunProcess={(a) => void runProcess(a)} onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)} onSaveUrlConfig={(c) => saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
onCopy={copyText} onCopy={copyText}
fetchJson={fetchJson} fetchJson={fetchJson}
/> />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Cable, Copy, Monitor, Radio } from "lucide-react"; import { Cable, Copy, Monitor, Radio } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard"; import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
@@ -53,15 +53,8 @@ type Props = {
setCurrentProc: (v: string) => void; setCurrentProc: (v: string) => void;
urlConfig: string; urlConfig: string;
setUrlConfig: (v: string) => void; setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
businessYoutubeKey: string;
setBusinessYoutubeKey: (v: string) => void;
syncYoutubeIni: boolean;
setSyncYoutubeIni: (v: boolean) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void; onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void; onSaveUrlConfig: (content?: string) => void | Promise<void>;
onSaveBusinessMeta: () => void;
onCopy: (text: string) => void; onCopy: (text: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>; fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
}; };
@@ -74,15 +67,8 @@ export function LiveTiktokHdmiView({
setCurrentProc, setCurrentProc,
urlConfig, urlConfig,
setUrlConfig, setUrlConfig,
douyinUrl,
setDouyinUrl,
businessYoutubeKey,
setBusinessYoutubeKey,
syncYoutubeIni,
setSyncYoutubeIni,
onRunProcess, onRunProcess,
onSaveUrlConfig, onSaveUrlConfig,
onSaveBusinessMeta,
onCopy, onCopy,
fetchJson, fetchJson,
}: Props) { }: Props) {
@@ -91,9 +77,37 @@ export function LiveTiktokHdmiView({
const [expanded, setExpanded] = useState<string | null>("1080p30"); const [expanded, setExpanded] = useState<string | null>("1080p30");
const [selectedPreset, setSelectedPreset] = useState<string>("1080p30"); const [selectedPreset, setSelectedPreset] = useState<string>("1080p30");
const [urlReloading, setUrlReloading] = useState(false); const [urlReloading, setUrlReloading] = useState(false);
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
const [hdmiNotes, setHdmiNotes] = useState(""); const [hdmiNotes, setHdmiNotes] = useState("");
const [checks, setChecks] = useState<Record<string, boolean>>({}); const [checks, setChecks] = useState<Record<string, boolean>>({});
useEffect(() => {
urlListDirtyRef.current = false;
}, [currentProc]);
const pullUrlConfigFromServer = useCallback(async () => {
if (!currentProc) return;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content ?? "");
} catch {
/* ignore */
}
}, [currentProc, fetchJson, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
const id = window.setInterval(() => {
if (urlListDirtyRef.current) return;
void pullUrlConfigFromServer();
}, 8000);
return () => clearInterval(id);
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
useEffect(() => { useEffect(() => {
try { try {
const raw = window.localStorage.getItem(CHECK_KEY); const raw = window.localStorage.getItem(CHECK_KEY);
@@ -129,18 +143,30 @@ export function LiveTiktokHdmiView({
const reloadUrlConfig = async () => { const reloadUrlConfig = async () => {
if (!currentProc) return; if (!currentProc) return;
setUrlReloading(true); setUrlReloading(true);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
try { try {
const data = await fetchJson<{ content?: string }>( const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`, `/get_url_config?process=${encodeURIComponent(currentProc)}`,
); );
setUrlConfig(data.content || ""); setUrlConfig(data.content ?? "");
} catch { setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
/* parent may show via busy */ } catch (e) {
setUrlFetchNote((e as Error).message);
} finally { } finally {
setUrlReloading(false); setUrlReloading(false);
} }
}; };
const saveUrlAndClearDirty = async () => {
try {
await Promise.resolve(onSaveUrlConfig());
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
const lock = busy || urlReloading; const lock = busy || urlReloading;
const presetLabel = PRESETS.find((p) => p.id === selectedPreset); const presetLabel = PRESETS.find((p) => p.id === selectedPreset);
@@ -281,48 +307,6 @@ export function LiveTiktokHdmiView({
</div> </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("业务元数据(配置中心)", "Hub business metadata")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("与 YouTube 页共用一份 business JSONHDMI/TikTok 场景可只填抖音 URL。", "Shared business JSON with YouTube page.")}
</p>
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
<input
className="mt-1 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/..."
/>
<label className="mt-3 block text-xs text-zinc-400">
{t("YouTube 串流密钥(声明式,可选)", "YouTube key (optional declarative)")}
</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
spellCheck={false}
autoComplete="off"
value={businessYoutubeKey}
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
placeholder="xxxx-xxxx…"
/>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
className="mt-0.5 rounded border-white/20 bg-black/40"
checked={syncYoutubeIni}
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
/>
<span>{t("同时写入 config/youtube.ini 的 key=", "Also sync key= to config/youtube.ini")}</span>
</label>
<button
type="button"
disabled={lock}
onClick={() => void onSaveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium 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"> <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"> <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> <h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
@@ -335,11 +319,31 @@ export function LiveTiktokHdmiView({
{t("复制全文", "Copy all")} {t("复制全文", "Copy all")}
</button> </button>
</div> </div>
<p className="mt-1 text-xs text-zinc-500">{t("一行一个地址;与 YouTube 单频道页相同维护方式。", "One URL per line; same as YouTube single.")}</p> <p className="mt-1 text-xs text-zinc-500">
{t(
"一行一个地址;可勾选自动从服务器刷新,或点「重新读取」手动同步。",
"One URL per line; enable auto-refresh or reload from server.",
)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={urlAutoRefresh}
onChange={(e) => setUrlAutoRefresh(e.target.checked)}
/>
{t("每 8 秒自动从服务器刷新", "Auto-refresh from server every 8s")}
</label>
</div>
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea <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" 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} value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)} onChange={(e) => {
urlListDirtyRef.current = true;
setUrlConfig(e.target.value);
}}
spellCheck={false} spellCheck={false}
/> />
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@@ -354,7 +358,7 @@ export function LiveTiktokHdmiView({
<button <button
type="button" type="button"
disabled={lock} disabled={lock}
onClick={() => onSaveUrlConfig()} onClick={() => void saveUrlAndClearDirty()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30" className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
> >
{t("保存 URL 配置", "Save URL config")} {t("保存 URL 配置", "Save URL config")}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react"; import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
buildYoutubeIniFromFields, buildYoutubeIniFromFields,
@@ -36,15 +36,8 @@ type Props = {
setCurrentProc: (v: string) => void; setCurrentProc: (v: string) => void;
urlConfig: string; urlConfig: string;
setUrlConfig: (v: string) => void; setUrlConfig: (v: string) => void;
douyinUrl: string; onRunProcess: (a: "start" | "stop" | "restart", processPm2?: string) => void;
setDouyinUrl: (v: string) => void; onSaveUrlConfig: (content?: string) => void | Promise<void>;
businessYoutubeKey: string;
setBusinessYoutubeKey: (v: string) => void;
syncYoutubeIni: boolean;
setSyncYoutubeIni: (v: boolean) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>; fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
}; };
@@ -56,15 +49,8 @@ export function LiveYoutubeUnmannedView({
setCurrentProc, setCurrentProc,
urlConfig, urlConfig,
setUrlConfig, setUrlConfig,
douyinUrl,
setDouyinUrl,
businessYoutubeKey,
setBusinessYoutubeKey,
syncYoutubeIni,
setSyncYoutubeIni,
onRunProcess, onRunProcess,
onSaveUrlConfig, onSaveUrlConfig,
onSaveBusinessMeta,
fetchJson, fetchJson,
}: Props) { }: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]); const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
@@ -83,6 +69,57 @@ export function LiveYoutubeUnmannedView({
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null); const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
const [ytIniLoading, setYtIniLoading] = useState(false); const [ytIniLoading, setYtIniLoading] = useState(false);
const [proUrlDraft, setProUrlDraft] = useState(""); const [proUrlDraft, setProUrlDraft] = useState("");
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
const [urlReloading, setUrlReloading] = useState(false);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
useEffect(() => {
urlListDirtyRef.current = false;
}, [currentProc]);
const pullUrlConfigFromServer = useCallback(async () => {
if (!currentProc) return;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
} catch {
/* ignore */
}
}, [currentProc, fetchJson, mode, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
const id = window.setInterval(() => {
if (urlListDirtyRef.current) return;
void pullUrlConfigFromServer();
}, 8000);
return () => clearInterval(id);
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
const reloadUrlManual = async () => {
if (!currentProc) return;
setUrlReloading(true);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) {
setUrlFetchNote((e as Error).message);
} finally {
setUrlReloading(false);
}
};
useEffect(() => { useEffect(() => {
try { try {
@@ -142,6 +179,31 @@ export function LiveYoutubeUnmannedView({
const active = channels.find((c) => c.id === activeCh) || channels[0]; const active = channels.find((c) => c.id === activeCh) || channels[0];
const proLanesWithKey = useMemo(
() => channels.filter((ch) => (ch.streamKey ?? "").trim().length > 0),
[channels],
);
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
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);
},
[active, mode, channels, proUrlDraft, urlConfig, persistChannels],
);
useEffect(() => {
if (mode !== "pro" || !activeCh) return;
const ch = channels.find((x) => x.id === activeCh);
if (!ch) return;
const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id);
if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want);
}, [mode, activeCh, channels, processOptions, setCurrentProc]);
const loadYoutubeIni = useCallback(async () => { const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return; if (!currentProc) return;
setYtIniLoading(true); setYtIniLoading(true);
@@ -213,7 +275,21 @@ export function LiveYoutubeUnmannedView({
if (!active) return; if (!active) return;
updateActive({ urlLines: proUrlDraft }); updateActive({ urlLines: proUrlDraft });
setUrlConfig(proUrlDraft); setUrlConfig(proUrlDraft);
onSaveUrlConfig(proUrlDraft); try {
await Promise.resolve(onSaveUrlConfig(proUrlDraft));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
const saveSingleUrlAndClearDirty = async () => {
try {
await Promise.resolve(onSaveUrlConfig());
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
}; };
const pushProStreamKeyToServer = async () => { const pushProStreamKeyToServer = async () => {
@@ -230,7 +306,29 @@ export function LiveYoutubeUnmannedView({
updateActive({ streamKey: f.key }); updateActive({ streamKey: f.key });
}; };
const lock = busy || ytIniLoading; const lock = busy || ytIniLoading || urlReloading;
const urlToolbar = (
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={urlAutoRefresh}
onChange={(e) => setUrlAutoRefresh(e.target.checked)}
/>
{t("每 8 秒自动从服务器刷新", "Auto-refresh from server every 8s")}
</label>
<button
type="button"
disabled={lock}
onClick={() => void reloadUrlManual()}
className="rounded-lg border border-white/10 px-3 py-1.5 text-zinc-200 hover:bg-white/5"
>
{t("手动重新读取", "Reload from server")}
</button>
</div>
);
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
@@ -246,8 +344,8 @@ export function LiveYoutubeUnmannedView({
</h2> </h2>
<p className="mt-0.5 text-xs text-zinc-500"> <p className="mt-0.5 text-xs text-zinc-500">
{t( {t(
"对齐 d2y 桌面懒人包「直播」页:单路 / Pro、无 Google 登录Linux 推荐 PM2 或 systemd 托管下方所选进程。底部为实时日志(轮询 PM2/本地日志文件)。", "单路一条流;多路时每条线路一把密钥、一个进程名,填好密钥和直播地址后保存,再点「开始」。无需 Google 登录。",
"Like d2y desktop: single/Pro, no Google login; on Linux use PM2/systemd for the selected process. Bottom: live logs from PM2/local log files.", "Single lane or multi-lane: one key and one process per lane — fill key & URLs, save, then Start. No Google login.",
)} )}
</p> </p>
</div> </div>
@@ -280,54 +378,128 @@ export function LiveYoutubeUnmannedView({
<Radio className="h-4 w-4" /> <Radio className="h-4 w-4" />
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3> <h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
</div> </div>
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
{t("进程PM2", "PM2 process")} {mode === "single" ? (
</label> <>
<select <label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" {t("转播任务", "Relay task")}
value={currentProc} </label>
onChange={(e) => setCurrentProc(e.target.value)} <select
> className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
{processOptions.map((e) => ( value={currentProc}
<option key={e.pm2} value={e.pm2}> onChange={(e) => setCurrentProc(e.target.value)}
{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} {processOptions.map((e) => (
</button> <option key={e.pm2} value={e.pm2}>
))} {e.label} ({e.pm2})
</div> </option>
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600"> ))}
key {t("尾码(参考)", "suffix")}: {keySuffixUi || "—"} </select>
</p> <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>
</>
) : (
<>
<p className="mt-3 text-xs text-zinc-500">
{t("点选一行即可编辑该线路;此处只列出已保存过密钥的线路。", "Pick a row to edit. Only lanes with a saved key are listed.")}
</p>
{proLanesWithKey.length === 0 ? (
<p className="mt-4 rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-zinc-400">
{channels.length > 0
? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.")
: t("请先新增线路。", "Add a lane first.")}
</p>
) : (
<div className="mt-4 space-y-2">
{proLanesWithKey.map((c) => {
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => selectProChannel(c)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectProChannel(c);
}
}}
className={`rounded-xl border p-3 text-left transition ${
sel ? "border-violet-500/45 bg-violet-500/[0.08]" : "border-white/10 bg-black/30 hover:border-white/20"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
<code className="shrink-0 font-mono text-xs text-cyan-300/90">{suffix || "—"}</code>
<span className="truncate text-sm text-zinc-200">{c.name}</span>
</div>
<div className="flex shrink-0 flex-wrap gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("start", pm)}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-100 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
{t("开始", "Start")}
</button>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("stop", pm)}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-100 ring-1 ring-rose-500/25 disabled:opacity-30"
>
{t("停止", "Stop")}
</button>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("restart", pm)}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-100 ring-1 ring-amber-500/25 disabled:opacity-30"
>
{t("重新开始", "Restart")}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
{t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"}
</p>
</>
)}
</div> </div>
{mode === "single" ? ( {mode === "single" ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6"> <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> <h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
{t("编辑 config/youtube.ini(与桌面端一致字段)。不使用 Google 登录。", "Edits config/youtube.ini. No Google OAuth.")} {t("串流参数写进 youtube.ini,无需 Google 登录。", "Stream settings go to youtube.ini — no Google login.")}
</p> </p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -426,7 +598,7 @@ export function LiveYoutubeUnmannedView({
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-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"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("Pro 线路列表", "Pro channels")}</h3> <h3 className="text-sm font-semibold text-white">{t("当前线路", "Current lane")}</h3>
<button <button
type="button" type="button"
disabled={lock} disabled={lock}
@@ -447,33 +619,24 @@ export function LiveYoutubeUnmannedView({
}} }}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
{t("新增线路", "Add")} {t("新增线路", "Add lane")}
</button> </button>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <label className="mt-3 block text-xs font-medium text-zinc-500">{t("切换线路", "Switch lane")}</label>
<select
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2.5 text-sm text-white"
value={activeCh}
onChange={(e) => {
const c = channels.find((x) => x.id === e.target.value);
if (c) selectProChannel(c);
}}
>
{channels.map((c) => ( {channels.map((c) => (
<button <option key={c.id} value={c.id}>
key={c.id} {c.name}
type="button" </option>
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> </select>
{active ? ( {active ? (
<div className="mt-4 space-y-3 border-t border-white/5 pt-4"> <div className="mt-4 space-y-3 border-t border-white/5 pt-4">
@@ -481,51 +644,41 @@ export function LiveYoutubeUnmannedView({
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm" className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
value={active.name} value={active.name}
onChange={(e) => updateActive({ name: e.target.value })} onChange={(e) => updateActive({ name: e.target.value })}
placeholder={t("线路显示名", "Channel name")} placeholder={t("线路名", "Lane name")}
/> />
<input <label className="block text-xs text-zinc-500">{t("串流密钥", "Stream key")}</label>
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 <input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm" className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
spellCheck={false} spellCheck={false}
value={active.streamKey ?? ""} value={active.streamKey ?? ""}
onChange={(e) => updateActive({ streamKey: e.target.value })} onChange={(e) => updateActive({ streamKey: e.target.value })}
placeholder="key / rtmp(s) URL" placeholder="xxxx-xxxx… / rtmp(s)://…"
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
disabled={lock} disabled={lock}
onClick={() => void pushProStreamKeyToServer()} 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" className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
> >
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")} {t("保存", "Save")}
</button> </button>
</div> </div>
<div <details className="rounded-xl border border-white/5 bg-black/20 p-3">
role="alert" <summary className="cursor-pointer text-xs text-zinc-500">{t("高级:进程名", "Advanced: process name")}</summary>
className="rounded-xl border border-rose-500/45 bg-rose-950/35 p-3 ring-1 ring-rose-500/25" <input
> className="mt-2 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
<p className="text-xs font-semibold text-rose-100/95"> value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
{t("覆盖警告", "Overwrite warning")} onChange={(e) => updateActive({ pm2Name: e.target.value })}
</p> spellCheck={false}
<p className="mt-1 text-[11px] leading-relaxed text-rose-100/80"> placeholder="youtube2__…"
{t( />
"服务器上通常只有一份 youtube.ini点此推送会立刻覆盖其中 key=;多路并行请在板上为每路使用独立 PM2 与独立 config 目录,或轮流推送后再开播。", </details>
"Hosts usually have a single youtube.ini: this overwrites key=. For parallel lanes use separate PM2 + config dirs per lane, or push then start one at a time.",
)}
</p>
</div>
<textarea <textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300" 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 ?? ""} value={active.notes ?? ""}
onChange={(e) => updateActive({ notes: e.target.value })} onChange={(e) => updateActive({ notes: e.target.value })}
placeholder={t("备注", "Notes")} placeholder={t("备注(可选)", "Notes (optional)")}
/> />
<button <button
type="button" type="button"
@@ -538,7 +691,7 @@ export function LiveYoutubeUnmannedView({
}} }}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{t("删除线路", "Remove")} {t("删除线路", "Delete lane")}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -546,10 +699,15 @@ export function LiveYoutubeUnmannedView({
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6"> <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> <h3 className="text-sm font-semibold text-white">{t("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea <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" 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} value={proUrlDraft}
onChange={(e) => setProUrlDraft(e.target.value)} onChange={(e) => {
urlListDirtyRef.current = true;
setProUrlDraft(e.target.value);
}}
spellCheck={false} spellCheck={false}
placeholder="https://live.douyin.com/..." placeholder="https://live.douyin.com/..."
/> />
@@ -558,12 +716,13 @@ export function LiveYoutubeUnmannedView({
type="button" type="button"
disabled={lock} disabled={lock}
onClick={() => { onClick={() => {
urlListDirtyRef.current = true;
setProUrlDraft(urlConfig); setProUrlDraft(urlConfig);
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft")); setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
}} }}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300" className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
> >
{t("与全局 URL 同步", "Sync from global")} {t("填入全局地址", "Use global URLs")}
</button> </button>
<button <button
type="button" type="button"
@@ -571,82 +730,37 @@ export function LiveYoutubeUnmannedView({
onClick={() => void saveProUrlAndServer()} 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" 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")} {t("保存地址", "Save URLs")}
</button> </button>
</div> </div>
</div> </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("业务元数据(配置中心)", "Hub business metadata")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"写入 business/douyinyoutube.json可与下方选项同步到仓库 config/youtube.ini。",
"Writes business/douyinyoutube.json; optional sync to config/youtube.ini.",
)}
</p>
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
<input
className="mt-1 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/..."
/>
<label className="mt-3 block text-xs text-zinc-400">
{t("YouTube 串流密钥(声明式 youtube_rtmp_key", "YouTube stream key (declarative)")}
</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
spellCheck={false}
autoComplete="off"
value={businessYoutubeKey}
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
placeholder="xxxx-xxxx… 或完整 rtmp(s) URL"
/>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
<input
type="checkbox"
className="mt-0.5 rounded border-white/20 bg-black/40"
checked={syncYoutubeIni}
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
/>
<span>
{t(
"同时写入本仓库(进程工作目录)下的 config/youtube.ini 中的 key=(需 Web 已配置 repo 根目录)",
"Also write key= to repo config/youtube.ini (FastAPI repo root must match this deployment).",
)}
</span>
</label>
<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"> <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> <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"> <p className="mt-1 text-xs text-zinc-500">
{mode === "pro" {mode === "pro"
? t("单频道模式在此编辑全局文件Pro 线路请用上方「当前线路」块保存。", "Pro lanes use the lane editor above.") ? t("Pro 模式在上方「当前线路」里编辑本线路地址。", "Pro: edit this lanes URLs in the lane panel above.")
: t("一行一个直播间地址。", "One URL per line.")} : t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
</p> </p>
{mode === "single" ? ( {mode === "single" ? (
<> <>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea <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" 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} value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)} onChange={(e) => {
urlListDirtyRef.current = true;
setUrlConfig(e.target.value);
}}
spellCheck={false} spellCheck={false}
/> />
<button <button
type="button" type="button"
disabled={lock} disabled={lock}
onClick={() => void onSaveUrlConfig()} onClick={() => void saveSingleUrlAndClearDirty()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30" 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")} {t("保存 URL 配置", "Save URL config")}