's'
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
20
src/utils.py
20
src/utils.py
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 JSON;HDMI/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")}
|
||||||
|
|||||||
@@ -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 lane’s 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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user