This commit is contained in:
eric
2026-05-19 11:58:20 +00:00
parent b617555360
commit bae55ec52c
18 changed files with 136 additions and 35 deletions

View File

@@ -776,6 +776,7 @@ ensure_runtime_dirs_owned_by_live() {
backup_config \ backup_config \
.pm2 \ .pm2 \
.process_runner \ .process_runner \
web-console \
web-console/out \ web-console/out \
web-console/.next \ web-console/.next \
web-console/node_modules \ web-console/node_modules \
@@ -784,6 +785,7 @@ ensure_runtime_dirs_owned_by_live() {
path="$ROOT_DIR/$rel" path="$ROOT_DIR/$rel"
mkdir -p "$path" mkdir -p "$path"
chown -R live:live "$path" 2>/dev/null || true chown -R live:live "$path" 2>/dev/null || true
chmod -R u+rwX,g+rwX "$path" 2>/dev/null || true
done done
} }
@@ -952,11 +954,14 @@ StartLimitIntervalSec=0
[Service] [Service]
User=live User=live
PermissionsStartOnly=true
WorkingDirectory=$ROOT_DIR WorkingDirectory=$ROOT_DIR
Environment=HOST=0.0.0.0 Environment=HOST=0.0.0.0
Environment=PORT=$PORT Environment=PORT=$PORT
Environment=LIVE_NOFILE_SOFT=$LIVE_NOFILE_SOFT Environment=LIVE_NOFILE_SOFT=$LIVE_NOFILE_SOFT
LimitNOFILE=$LIVE_NOFILE_SOFT LimitNOFILE=$LIVE_NOFILE_SOFT
ExecStartPre=/usr/bin/install -d -o live -g live $ROOT_DIR/config $ROOT_DIR/logs $ROOT_DIR/downloads $ROOT_DIR/backup $ROOT_DIR/backup_config $ROOT_DIR/.pm2 $ROOT_DIR/.process_runner $ROOT_DIR/web-console $ROOT_DIR/services/neko/data
ExecStartPre=/bin/chown -R live:live $ROOT_DIR/config $ROOT_DIR/logs $ROOT_DIR/downloads $ROOT_DIR/backup $ROOT_DIR/backup_config $ROOT_DIR/.pm2 $ROOT_DIR/.process_runner $ROOT_DIR/web-console $ROOT_DIR/services/neko/data
ExecStart=$ROOT_DIR/start.sh ExecStart=$ROOT_DIR/start.sh
Restart=always Restart=always
RestartSec=5 RestartSec=5

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import sqlite3
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -276,6 +277,11 @@ async def hub_youtube_pro_channels_write(body: YoutubeProChannelsBody) -> dict[s
set_youtube_pro_channels(body.channels) set_youtube_pro_channels(body.channels)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except sqlite3.Error as exc:
raise HTTPException(
status_code=500,
detail=f"多频道状态数据库写入失败: {exc}; 请确认 config 目录归 live 用户可写",
) from exc
capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, _repo_root, body.channels) capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, _repo_root, body.channels)
return {"message": "ok", "neko": capacity} return {"message": "ok", "neko": capacity}

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import ast import ast
import asyncio import asyncio
import json import json
import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -19,6 +20,15 @@ class _JsonRequest:
return self._payload return self._payload
class _ReadRequest:
headers: dict[str, str] = {}
class _Client:
host = "127.0.0.1"
client = _Client()
def _json_body(resp) -> dict[str, object]: # noqa: ANN001 def _json_body(resp) -> dict[str, object]: # noqa: ANN001
return json.loads(resp.body) return json.loads(resp.body)
@@ -180,7 +190,7 @@ class WebDynamicProcessesTests(unittest.TestCase):
with patch("web.allows_url_config", return_value=True): with patch("web.allows_url_config", return_value=True):
with patch("web.managed_url_config_relpath", return_value=rel): with patch("web.managed_url_config_relpath", return_value=rel):
with patch("web._sync_youtube_pro_runtime_files", side_effect=AssertionError("unexpected resync")): with patch("web._sync_youtube_pro_runtime_files", side_effect=AssertionError("unexpected resync")):
payload = asyncio.run(web.get_url_config("youtube")) payload = asyncio.run(web.get_url_config(_ReadRequest(), process="youtube"))
self.assertEqual(_json_body(payload)["content"], "https://live.douyin.com/new\n") self.assertEqual(_json_body(payload)["content"], "https://live.douyin.com/new\n")
self.assertEqual(payload.headers.get("cache-control"), "no-store, max-age=0, must-revalidate") self.assertEqual(payload.headers.get("cache-control"), "no-store, max-age=0, must-revalidate")
@@ -204,6 +214,26 @@ class WebDynamicProcessesTests(unittest.TestCase):
self.assertEqual(_json_body(payload)["message"], "配置保存成功") self.assertEqual(_json_body(payload)["message"], "配置保存成功")
self.assertEqual(captured[-1][0]["urlLines"], "https://live.douyin.com/fresh\n") self.assertEqual(captured[-1][0]["urlLines"], "https://live.douyin.com/fresh\n")
def test_save_url_config_returns_json_when_channel_store_readonly(self) -> None:
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube2__lane1", "urlLines": "old\n"}]
with patch("web.allows_url_config", return_value=True):
with patch("web.managed_url_config_relpath", return_value="URL_config.youtube2__lane1.ini"):
with patch("web.governed_save_managed_file"):
with patch("web.get_youtube_pro_channels", return_value=channels):
with patch("web.set_youtube_pro_channels", side_effect=sqlite3.OperationalError("attempt to write a readonly database")):
payload = asyncio.run(
web.save_url_config(
_JsonRequest({"content": "https://live.douyin.com/fresh\n"}),
process="youtube2__lane1",
)
)
body = _json_body(payload)
self.assertEqual(payload.status_code, 500)
self.assertIn("多频道状态数据库写入失败", body["error"])
self.assertIn("config", body["error"])
def test_save_config_updates_matching_youtube_pro_channel_stream_key(self) -> None: def test_save_config_updates_matching_youtube_pro_channel_stream_key(self) -> None:
captured: list[list[dict[str, object]]] = [] captured: list[list[dict[str, object]]] = []
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube2__lane1", "streamKey": "old-key"}] channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube2__lane1", "streamKey": "old-key"}]

View File

@@ -370,6 +370,7 @@ export function LiveYoutubeUnmannedView({
const urlListDirtyRef = useRef(false); const urlListDirtyRef = useRef(false);
const singleUrlDraftRef = useRef(urlConfig); const singleUrlDraftRef = useRef(urlConfig);
const proUrlDraftRef = useRef(proUrlDraft); const proUrlDraftRef = useRef(proUrlDraft);
const channelsRef = useRef<YoutubeProChannel[]>([]);
const currentProcRef = useRef(currentProc); const currentProcRef = useRef(currentProc);
const urlRequestSeqRef = useRef(0); const urlRequestSeqRef = useRef(0);
const ytIniRequestSeqRef = useRef(0); const ytIniRequestSeqRef = useRef(0);
@@ -403,6 +404,27 @@ export function LiveYoutubeUnmannedView({
proUrlDraftRef.current = proUrlDraft; proUrlDraftRef.current = proUrlDraft;
}, [proUrlDraft]); }, [proUrlDraft]);
useEffect(() => {
channelsRef.current = channels;
}, [channels]);
const updateActiveChannelUrlLines = useCallback((channelId: string, nextUrlLines: string) => {
if (!channelId) return;
setChannels((prev) => {
let changed = false;
const next = prev.map((row) => {
if (row.id !== channelId) return row;
if ((row.urlLines ?? "") === nextUrlLines) return row;
changed = true;
return { ...row, urlLines: nextUrlLines };
});
if (!changed) return prev;
channelsRef.current = next;
saveYoutubeProChannels(next);
return next;
});
}, []);
const pullUrlConfigFromServer = useCallback(async () => { const pullUrlConfigFromServer = useCallback(async () => {
const targetProc = currentProcRef.current.trim(); const targetProc = currentProcRef.current.trim();
if (!targetProc) return; if (!targetProc) return;
@@ -418,24 +440,18 @@ export function LiveYoutubeUnmannedView({
if (urlListDirtyRef.current) return; if (urlListDirtyRef.current) return;
if (mode === "single") applySingleUrlDraft(c); if (mode === "single") applySingleUrlDraft(c);
else { else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; const localDraft = channelsRef.current.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) { if (shouldPreserveLocalUrlDraft(localDraft, c)) {
applyProUrlDraft(localDraft); applyProUrlDraft(localDraft);
return; return;
} }
applyProUrlDraft(c); applyProUrlDraft(c);
if (activeCh) { updateActiveChannelUrlLines(activeCh, c);
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
}
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
}, [activeCh, applyProUrlDraft, applySingleUrlDraft, channels, fetchJson, mode]); }, [activeCh, applyProUrlDraft, applySingleUrlDraft, fetchJson, mode, updateActiveChannelUrlLines]);
useEffect(() => { useEffect(() => {
if (!currentProc || !urlAutoRefresh) return; if (!currentProc || !urlAutoRefresh) return;
@@ -467,27 +483,21 @@ export function LiveYoutubeUnmannedView({
} }
if (mode === "single") applySingleUrlDraft(c); if (mode === "single") applySingleUrlDraft(c);
else { else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; const localDraft = channelsRef.current.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) { if (shouldPreserveLocalUrlDraft(localDraft, c)) {
applyProUrlDraft(localDraft); applyProUrlDraft(localDraft);
setUrlFetchNote(t("服务器暂无已保存 URL已保留本地草稿", "Server has no saved URLs, kept the local draft")); setUrlFetchNote(t("服务器暂无已保存 URL已保留本地草稿", "Server has no saved URLs, kept the local draft"));
return; return;
} }
applyProUrlDraft(c); applyProUrlDraft(c);
if (activeCh) { updateActiveChannelUrlLines(activeCh, c);
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
}
} }
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) { } catch (e) {
if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setUrlFetchNote((e as Error).message); setUrlFetchNote((e as Error).message);
} finally { } finally {
if (requestId === urlRequestSeqRef.current && targetProc === currentProcRef.current.trim()) { if (requestId === urlRequestSeqRef.current) {
setUrlReloading(false); setUrlReloading(false);
} }
} }
@@ -512,6 +522,7 @@ export function LiveYoutubeUnmannedView({
try { try {
const remote = await fetchYoutubeProChannelsRemote(fetchJson); const remote = await fetchYoutubeProChannelsRemote(fetchJson);
if (remote.fetched && remote.channels.length && !cancelled) { if (remote.fetched && remote.channels.length && !cancelled) {
channelsRef.current = remote.channels;
setChannels(remote.channels); setChannels(remote.channels);
saveYoutubeProChannels(remote.channels); saveYoutubeProChannels(remote.channels);
return; return;
@@ -537,6 +548,7 @@ export function LiveYoutubeUnmannedView({
}, },
]; ];
})(); })();
channelsRef.current = initial;
setChannels(initial); setChannels(initial);
if (!list.length) saveYoutubeProChannels(initial); if (!list.length) saveYoutubeProChannels(initial);
})(); })();
@@ -574,6 +586,7 @@ export function LiveYoutubeUnmannedView({
}, [currentProc, currentProcLooksYoutube, mode, preferredSingleProc, setCurrentProc]); }, [currentProc, currentProcLooksYoutube, mode, preferredSingleProc, setCurrentProc]);
const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => { const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => {
channelsRef.current = next;
setChannels(next); setChannels(next);
saveYoutubeProChannels(next); saveYoutubeProChannels(next);
}, []); }, []);
@@ -786,7 +799,7 @@ export function LiveYoutubeUnmannedView({
if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setYtIniStatus((e as Error).message); setYtIniStatus((e as Error).message);
} finally { } finally {
if (requestId === ytIniRequestSeqRef.current && targetProc === currentProcRef.current.trim()) { if (requestId === ytIniRequestSeqRef.current) {
setYtIniLoading(false); setYtIniLoading(false);
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"built_at": 1779182151.9524395, "built_at": 1779184553.6568675,
"source_mtime": 1779180451.7832174, "source_mtime": 1779183169.5914183,
"toolchain": "local", "toolchain": "local",
"node_major": 20 "node_major": 20
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,6 @@
3:I[3946,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","357","static/chunks/app/android/page-84550dabda18d658.js"],"default",1] 3:I[3946,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","357","static/chunks/app/android/page-84550dabda18d658.js"],"default",1]
4:I[4707,[],""] 4:I[4707,[],""]
5:I[6423,[],""] 5:I[6423,[],""]
0:["UmN0Ou_Iv2TWI4xcHvY2X",[[["",{"children":["android",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["android",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","android","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] 0:["Bqx_FrY3OzE8SRa_XO6pv",[[["",{"children":["android",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["android",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","android","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]
1:null 1:null

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,6 @@
3:I[6367,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","443","static/chunks/app/console/page-75d851a154677dbc.js"],"default",1] 3:I[6367,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","443","static/chunks/app/console/page-75d851a154677dbc.js"],"default",1]
4:I[4707,[],""] 4:I[4707,[],""]
5:I[6423,[],""] 5:I[6423,[],""]
0:["UmN0Ou_Iv2TWI4xcHvY2X",[[["",{"children":["console",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["console",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","console","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] 0:["Bqx_FrY3OzE8SRa_XO6pv",[[["",{"children":["console",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["console",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","console","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]
1:null 1:null

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"] 2:I[9107,[],"ClientPageRoot"]
3:I[1499,["500","static/chunks/500-ea37cc8de90441f7.js","719","static/chunks/719-4dbe9dca4cff8afd.js","246","static/chunks/246-b5d14933cf331e85.js","931","static/chunks/app/page-3c5cf076d3a5e7c5.js"],"default",1] 3:I[1499,["500","static/chunks/500-ea37cc8de90441f7.js","719","static/chunks/719-4dbe9dca4cff8afd.js","246","static/chunks/246-b5d14933cf331e85.js","931","static/chunks/app/page-a84b3c1d3686233c.js"],"default",1]
4:I[4707,[],""] 4:I[4707,[],""]
5:I[6423,[],""] 5:I[6423,[],""]
0:["UmN0Ou_Iv2TWI4xcHvY2X",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] 0:["Bqx_FrY3OzE8SRa_XO6pv",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]
1:null 1:null

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,6 @@
3:I[3398,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","6","static/chunks/app/shellcrash/page-3d8615ae919b9119.js"],"default",1] 3:I[3398,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-b5d14933cf331e85.js","6","static/chunks/app/shellcrash/page-3d8615ae919b9119.js"],"default",1]
4:I[4707,[],""] 4:I[4707,[],""]
5:I[6423,[],""] 5:I[6423,[],""]
0:["UmN0Ou_Iv2TWI4xcHvY2X",[[["",{"children":["shellcrash",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shellcrash",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shellcrash","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] 0:["Bqx_FrY3OzE8SRa_XO6pv",[[["",{"children":["shellcrash",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shellcrash",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shellcrash","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/693f7950f3222bce.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]]
1:null 1:null

View File

@@ -6,7 +6,9 @@ import json
import os import os
import re import re
import signal import signal
import sqlite3
import sys import sys
import time
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -362,6 +364,10 @@ def _persist_youtube_pro_channel_edits(
return updated return updated
def _youtube_pro_store_error(exc: sqlite3.Error) -> str:
return f"多频道状态数据库写入失败: {exc}; 请确认 config 目录归 live 用户可写"
def _validate_youtube_runtime_files(process: str) -> list[str]: def _validate_youtube_runtime_files(process: str) -> list[str]:
return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels()) return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels())
@@ -553,6 +559,8 @@ async def alias_youtube_pro_channels_post(body: YoutubeProChannelsBody):
set_youtube_pro_channels(body.channels) set_youtube_pro_channels(body.channels)
except ValueError as exc: except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400) return JSONResponse({"error": str(exc)}, status_code=400)
except sqlite3.Error as exc:
return JSONResponse({"error": _youtube_pro_store_error(exc)}, status_code=500)
capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, BASE_DIR, body.channels) capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, BASE_DIR, body.channels)
return {"message": "ok", "neko": capacity} return {"message": "ok", "neko": capacity}
@@ -570,6 +578,35 @@ _NO_STORE_HEADERS = {
"Expires": "0", "Expires": "0",
} }
_READ_CONFIG_MIN_INTERVAL_SECONDS = 0.45
_READ_CONFIG_LAST_SEEN: dict[tuple[str, str, str], float] = {}
def _client_host(request: Request) -> str:
forwarded = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
if forwarded:
return forwarded
return request.client.host if request.client else "unknown"
def _reject_config_read_storm(request: Request, endpoint: str, process: str) -> JSONResponse | None:
now = time.monotonic()
if len(_READ_CONFIG_LAST_SEEN) > 2048:
cutoff = now - 30.0
for key, seen_at in list(_READ_CONFIG_LAST_SEEN.items()):
if seen_at < cutoff:
_READ_CONFIG_LAST_SEEN.pop(key, None)
key = (_client_host(request), endpoint, (process or "").strip())
last = _READ_CONFIG_LAST_SEEN.get(key, 0.0)
_READ_CONFIG_LAST_SEEN[key] = now
if now - last >= _READ_CONFIG_MIN_INTERVAL_SECONDS:
return None
return JSONResponse(
{"error": "刷新过快,请稍后重试"},
status_code=429,
headers={**_NO_STORE_HEADERS, "Retry-After": "1"},
)
class ProcessNameBody(BaseModel): class ProcessNameBody(BaseModel):
process: str = Field(..., min_length=1, max_length=160) process: str = Field(..., min_length=1, max_length=160)
@@ -1112,7 +1149,10 @@ async def get_system_snapshot():
# ---------------- 配置路由youtube.ini仅 youtube ---------------- # ---------------- 配置路由youtube.ini仅 youtube ----------------
@app.get("/get_config") @app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")): async def get_config(request: Request, process: str = Query(..., description="进程名")):
storm = _reject_config_read_storm(request, "get_config", process)
if storm is not None:
return storm
rel = managed_youtube_ini_relpath(process) rel = managed_youtube_ini_relpath(process)
if not rel: if not rel:
return JSONResponse( return JSONResponse(
@@ -1156,13 +1196,18 @@ async def save_config(request: Request, process: str = Query(..., description="
governed_save_managed_file("app-config", rel, content) governed_save_managed_file("app-config", rel, content)
_persist_youtube_pro_channel_edits(process, youtube_ini_content=content) _persist_youtube_pro_channel_edits(process, youtube_ini_content=content)
return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS) return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS)
except sqlite3.Error as e:
return JSONResponse({"error": _youtube_pro_store_error(e)}, status_code=500)
except OSError as e: except OSError as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500) return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 配置路由URL_config.ini ---------------- # ---------------- 配置路由URL_config.ini ----------------
@app.get("/get_url_config") @app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")): async def get_url_config(request: Request, process: str = Query(..., description="进程名")):
storm = _reject_config_read_storm(request, "get_url_config", process)
if storm is not None:
return storm
if not allows_url_config(process): if not allows_url_config(process):
return JSONResponse( return JSONResponse(
{"error": "仅 tiktok*.py / youtube*.py / douyin_youtube*.py 对应进程支持 URL 配置编辑"}, {"error": "仅 tiktok*.py / youtube*.py / douyin_youtube*.py 对应进程支持 URL 配置编辑"},
@@ -1210,6 +1255,8 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
governed_save_managed_file("app-config", rel, content) governed_save_managed_file("app-config", rel, content)
_persist_youtube_pro_channel_edits(process, url_content=content) _persist_youtube_pro_channel_edits(process, url_content=content)
return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS) return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS)
except sqlite3.Error as e:
return JSONResponse({"error": _youtube_pro_store_error(e)}, status_code=500)
except OSError as e: except OSError as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500) return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)