fix: serve replay media from nginx and unbreak settings saves

Replay
- Hand the replay file to nginx with X-Accel-Redirect instead of streaming it
  through the backend. A replay is tens of gigabytes, so Python was copying the
  whole file and holding a worker for the entire transfer: measured 8 MB in
  8.46s (~1 MB/s). nginx serves it from disk with sendfile at ~400 MB/s (1 GB in
  2.68s) and the backend process does not appear in CPU usage during the transfer.
  Authentication and the path-traversal check still run in Python; only the bytes
  moved. The internal location is what keeps the path from being reachable
  directly and bypassing that check.
- nginx reloads and the media directory is aliased internally.

Settings
- GET /settings/transcode-profiles returned the bare mapping while the settings
  page reads `data.profiles`, so it always saw undefined, fell back to an empty
  object, and every save posted `{"profiles": {}}` — which the PUT rejects as
  "at least one profile". Saving settings could never succeed. The response now
  matches the shape PUT accepts, verified by round-tripping it.

Auth
- Derive the admin token instead of storing it. The token is
  sha256(password + ":token"), but it was kept in a module-level set, which only
  works with one process: with uvicorn --workers N, logging in on one worker left
  the others rejecting the token and the admin appeared logged out at random.

Viewer
- Show the real session id in the stats panel; the previous code assigned it
  from a local variable that was never populated, so it always read "不可用".
- Send the first heartbeat before the first command poll. Both fired together on
  mount, so the poll reached the backend before the session existed and 404'd on
  every page load.

Housekeeping
- Ignore backend/nodes.json; the admin node manager writes it at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-29 11:47:52 +08:00
1 parent 4ee945ca0d
commit cdf591f933
6 files changed
+78 -19

No files matched your search

+15 -2
View File
@@ -116,8 +116,15 @@ export default function WatchPage() {
}
}
void heartbeat()
void pollCommand()
// The heartbeat must land before the first poll: the backend only knows a
// session once it has been told about one, and polling first gets a 404 for a
// session that is about to exist. Chaining them removes that guaranteed
// first-request failure without adding a timer.
const startPolling = async () => {
await heartbeat()
await pollCommand()
}
void startPolling()
const heartbeatTimer = window.setInterval(heartbeat, 10000)
const commandTimer = window.setInterval(pollCommand, 3000)
return () => {
@@ -389,6 +396,12 @@ export default function WatchPage() {
['缓冲', `${playbackStats.buffered.toFixed(1)} 秒`],
['丢帧', String(playbackStats.droppedFrames)],
['播放地址', getVideo()?.currentSrc || '加载中'],
// Read from the ref, not a local copy: the session id is
// created in an effect (after the first render) and replaced
// whenever the backend reports this one belongs to another
// programme, so a value captured during render would be blank
// on load and stale after a rebind.
['会话标识', viewerSessionRef.current || '初始化中'],
].map(([label, value]) => (
<div key={label}>
<span className="text-muted-foreground">{label}</span>