SyncTube 1.0.0 API 使用详解

SyncTube 1.0.0 无 REST API,程序化对接唯一通道为 WebSocket 消息协议:35 个事件逐域详解(认证/聊天/播放列表/播放控制/管理诊断)附实测回显,外加 5 条 stdin 终端命令实测与 /replay 崩溃缺陷预警。

SyncTube自托管WebSocketNodejsSyncTube2026/10/30

SyncTube 1.0.0 API 使用详解

目标:讲清 SyncTube 1.0.0 的程序化对接方式——它没有 REST API,唯一程序接口是 WebSocket 消息协议(35 个事件)与服务进程的 5 条 stdin 终端命令。本文每个请求/响应均用 Python websocket-client 在 Ubuntu 22.04(Node.js v20.18.1)实测,回显逐行照抄(敏感值已脱敏)。

一、环境信息

本文所有实测结论来自以下环境,读者复现时保持相近版本即可:

  • 操作系统:Ubuntu 22.04(x86_64)
  • SyncTube:1.0.0(源码 master 分支,钉住提交 7b0ea58,2026-08-23)
  • Node.js:v20.18.1(node -v 实测)
  • 服务端点:ws://192.168.1.10:4200/(与网页同端口)
  • 嵌入源:局域网内另一台服务器 192.168.1.10:8080,由 nginx 提供 sample.mp4 与 stream.m3u8(HLS)测试媒体
  • 客户端工具:Python 3.10 + websocket-client(pip3 install websocket-client)
  • 示例账号:管理员 admin / q1w2e3r4(《Ubuntu 22.04 安装 SyncTube 1.0.0 指南》4.5 节初始化)
python3 -c "import websocket; print(websocket.__version__)"
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.1.10:4200/

实测回显:

1.8.0
200

二、前置条件(端点、认证与客户端工具)

2.1 唯一端点与消息信封

一切交互走 一个 WebSocket 端点:ws://<host>:4200/——与网页同一端口,HTTP 升级协议后复用连接。没有 /api/ 前缀、没有版本化路径、没有认证专用端点,端点清单一句话说完了。

每条消息是一个 JSON 对象,采用信封模式:type 字段为事件名,同名(首字母小写)字段承载载荷:

{"type": "Message", "message": {"clientName": "alice", "text": "你好"}}

协议版本在连接握手的 Connected 推送里给出,本文实测 serverVersion=2。客户端与服务端共用同一套事件定义(源码 src/Types.hx 的 WsEventType 枚举),请求与推送只是方向不同。

💡 技术拓展:{"type": "X", "x": {...}} 这种"类型标签 + 同名词载荷"的信封是消息协议的经典设计(对比 REST 的"路径区分资源"):解析端先读 type 再派发到同名载荷处理器,加新事件只需扩展枚举不破坏旧消息;代价是每条消息多一层嵌套。同类协议中 MQTT 用主题区分、gRPC 用方法号区分,SyncTube 选择可读性最高的自描述信封——排查问题时抓包直接肉眼可读。

2.2 认证方式(匿名即游客,管理员走口令哈希)

  • 不登录:连接后即以游客(Guest)身份在频道内,拥有 permissions.guest 配置的权限;
  • 昵称登录:发送 Login {clientName} 即以该昵称成为"注册用户"(group 含 User=2);
  • 管理员登录:用已在 user/users.json 注册的名字发送 Login {clientName, passHash}。passHash 不是明文密码,而是 sha256(明文密码 + salt) 的十六进制——salt 由服务端在 Connected 推送中给出(每个部署实例固定一个随机盐)。

身份用 group 整数位标志表达,实测取值:

group位组成含义
0—未登录游客(Guest N)
2User已登录普通用户
3Banned + User被封禁中的用户(实测见 5.5 节)
6User + Leader取得放映权的用户
10User + Admin管理员
14User + Leader + Admin取得放映权的管理员(实测见 5.5 节)

位权重:Banned=1、User=2、Leader=4、Admin=8,按位或叠加。

2.3 客户端工具选择

工具适用说明
Python websocket-client本文实测、写机器人同步 API,create_connection 一行连接
wscat(npm 全局包)手工交互调试wscat -c ws://192.168.1.10:4200/ 后逐行粘贴 JSON
浏览器 DevTools抓官方前端流量Network → WS 面板直接看网页自身的收发

安装本文实测工具:

pip3 install websocket-client

实测回显(已装则显示 satisfied):

Requirement already satisfied: websocket-client

三、API 总览(35 个事件清单)

服务端定义的事件共 35 个(src/Types.hx WsEventType 枚举逐一计数),按功能分六域。方向标注:发=客户端可发送,收=服务端推送(多数事件双向):

域事件(方向)
连接与在线状态(4)Connected(收)、Disconnected(收)、UpdateClients(发/收)、Progress(收,缓存进度)
认证(4)Login(发/收)、PasswordRequest(收)、LoginError(收)、Logout(发/收)
聊天(3)Message(发/收)、ServerMessage(收)、ClearChat(发/收)
播放列表(11)AddVideo(发/收)、RemoveVideo(发/收)、SkipVideo(发/收)、SkipVideoStats(收)、PlayItem(发/收)、SetNextItem(发/收)、ToggleItemType(发/收)、UpdatePlaylist(发/收)、ClearPlaylist(发/收)、ShufflePlaylist(发)、TogglePlaylistLock(发/收)
播放控制(9)VideoLoaded(发/收)、GetTime(发/收)、SetTime(发/收)、Pause(发/收)、Play(发/收)、SetRate(发/收)、Rewind(发/收)、Flashback(发/收)、SetLeader(发/收)
管理与诊断(4)BanClient(发/收)、KickClient(发/收)、Dump(发/收,仅管理员)、CrashTest(发,仅管理员,触发进程崩溃自检)

三个要点先行:

  1. 无分页、无过滤参数:列表类状态(播放列表、聊天历史、在线名单)一律全量快照语义——UpdatePlaylist 返回完整 videoList,Connected 一次性下发 history(上限为配置的 serverChatHistory 条)与当前 clients。数据量由配置参数封顶(totalVideoLimit、serverChatHistory),不存在游标或 offset;
  2. 响应模式有三种:单播回显(如 GetTime 只回请求者)、全员广播(如 Message、Pause)、除发送者外广播(如 SetRate——发送者收不到自己的回显,实测见 5.3 节);
  3. 错误不走 HTTP 状态码:权限不足、参数错误一律以 ServerMessage(结构化 textId)或协议级错误事件(LoginError)回给发送者。

四、认证与首次调用(实测)

4.1 连接即初始化:Connected 推送

建立 WebSocket 连接后,服务端无条件先推一条 Connected,这是整场会话的状态快照(实测回显节选,emotes 37 项与 filters 2 项折叠,youtubeApiKey 已脱敏):

{
 "type": "Connected",
 "connected": {
  "uuid": "967ca4a5-a716-40c0-b75f-a86608d5c3d0",
  "config": {
   "channelName": "我的观影频道",
   "isVerbose": false,
   "maxLoginLength": 20,
   "maxMessageLength": 200,
   "port": 4200,
   "requestLeaderOnPause": true,
   "salt": "0dae25b521e9ded61b0a218fc67d4a3c56cc3d7674339f42b922d7bb650d57b7",
   "serverVersion": 2,
   "totalVideoLimit": 0,
   "unpauseWithoutLeader": false,
   "userVideoLimit": 0,
   "voteSkipRatio": 0.51,
   "youtubeApiKey": "${YOUTUBE_API_KEY}",
   "youtubePlaylistLimit": 50,
   "permissions": {"...": "四级展开后的权限表,见《SyncTube 1.0.0 配置参数详解》5.7 节"},
   "emotes": "<37 项>",
   "filters": "<2 项>"
  },
  "history": [
   {"text": "hello from ws probe", "name": "leader1", "time": "2026-10-30 02:08:20"}
  ],
  "isUnknownClient": true,
  "clientName": "Guest 2",
  "clients": [
   {"name": "admin", "group": 14},
   {"name": "Guest 2", "group": 0}
  ],
  "videoList": [
   {"author": "admin", "duration": 30, "isTemp": false, "playerType": "RawType",
    "title": "HLS 测试流", "url": "http://192.168.1.10:8080/stream.m3u8"}
  ],
  "isPlaylistOpen": true,
  "itemPos": 0,
  "globalIp": "182.54.113.29",
  "playersCacheSupport": ["RawType"]
 }
}

机器人开发的关键点:连接后的状态初始化全部依赖这条推送——昵称(clientName,未登录时是 Guest N)、在线名单、播放列表、播放进度都从这里拿,不要自己再逐项查询。

4.2 游客登录(Login)

import json, websocket
ws = websocket.create_connection("ws://192.168.1.10:4200/", timeout=6)
ws.settimeout(5)
json.loads(ws.recv())                                   # Connected
ws.send(json.dumps({"type": "Login", "login": {"clientName": "leader1"}}))
print(json.dumps(json.loads(ws.recv()), ensure_ascii=False, indent=1))

实测回显:

{
 "type": "Login",
 "login": {
  "isUnknownClient": true,
  "clientName": "leader1",
  "clients": [
   {
    "name": "leader1",
    "group": 2
   }
  ]
 }
}

响应的 clients 是全员在线名单(本例频道里只有自己),此后每次进出都有 UpdateClients 广播。

4.3 管理员口令登录(三步实测)

管理员登录的三条路径,完整实测(SALT 取自 4.1 的 Connected.config.salt):

import json, hashlib, websocket
URL, SALT = "ws://192.168.1.10:4200/", "0dae25b521e9ded61b0a218fc67d4a3c56cc3d7674339f42b922d7bb650d57b7"

def fresh_login(payload):
    ws = websocket.create_connection(URL, timeout=6); ws.settimeout(5)
    json.loads(ws.recv())                                # Connected
    ws.send(json.dumps({"type": "Login", "login": payload}))
    return ws

# a) 管理员名不带密码 -> PasswordRequest
ws = fresh_login({"clientName": "admin"})
print(json.dumps(json.loads(ws.recv()), ensure_ascii=False))

# b) 错误口令 -> passwordMatchError + LoginError
ws = fresh_login({"clientName": "admin", "passHash": "deadbeef" * 8})
print(json.dumps(json.loads(ws.recv()), ensure_ascii=False))
print(json.dumps(json.loads(ws.recv()), ensure_ascii=False))

# c) 正确口令 sha256(密码+salt) -> Login 且 group=10
good = hashlib.sha256(("q1w2e3r4" + SALT).encode()).hexdigest()
ws = fresh_login({"clientName": "admin", "passHash": good})
print(json.dumps(json.loads(ws.recv()), ensure_ascii=False))

实测回显(a → b → c):

{"type": "PasswordRequest"}
{"type": "ServerMessage", "serverMessage": {"textId": "passwordMatchError"}}
{"type": "LoginError"}
{"type": "Login", "login": {"isUnknownClient": true, "clientName": "admin", "clients": [{"name": "Guest 1", "group": 0}, {"name": "Guest 2", "group": 0}, {"name": "admin", "group": 10}]}}

解读:a 路径是两段式登录——服务端先回 PasswordRequest,客户端补发 Login {clientName, passHash} 即可;b 路径口令错时先收到 ServerMessage(passwordMatchError) 再收到协议级 LoginError,客户端两条都要处理;c 路径成功后 group=10(User+Admin)。passHash 的构造公式实测有效:sha256("q1w2e3r4" + salt) 十六进制小写。

4.4 认证相关错误码速查

响应触发条件处理建议
PasswordRequest用管理员名登录且未带 passHash补发带 passHash 的 Login
ServerMessage textId=passwordMatchError + LoginErrorpassHash 与 sha256(密码+salt) 不符检查 salt 是否取自当前实例
`ServerMessage textId=accessError<action>`权限不足(如游客 ClearChat)

实测游客越权(登录 guest1 后发 ClearChat,管理员缺位):

{"type": "ServerMessage", "serverMessage": {"textId": "accessError|clearChat"}}

五、各 API 域详解

5.1 聊天域(Message / ServerMessage / ClearChat)

发送聊天——Message {clientName, text},服务端全员广播(含发送者):

ws.send(json.dumps({"type": "Message",
                    "message": {"clientName": "leader1", "text": "hello from ws probe"}}))

实测回显(广播):

{
 "message": {
  "clientName": "leader1",
  "text": "hello from ws probe"
 },
 "type": "Message"
}

超长消息截断不拒绝:配置 maxMessageLength=200(《SyncTube 1.0.0 配置参数详解》5.1 节)后发送 300 字消息,广播回来的 text 恰为前 200 字。clientName 服务端不校验与登录名一致——广播时原样携带,但历史记录里服务端按连接身份落名,伪造 clientName 不影响实际归属。

ServerMessage 是服务端的系统通知(错误、提示),载荷只有一个 textId:结构化错误(passwordMatchError、accessError|addVideo、totalVideoLimitError 等)或诊断文本(Dump 的附带输出,见 5.5 节)。前端按 textId 查语言包渲染,机器人按 textId 前缀判断错误类型。

ClearChat:清空聊天历史(含所有人的屏幕与 serverChatHistory 缓冲),需 clearChat 权限(默认仅管理员)。实测管理员发送后广播 {"type": "ClearChat"},游客发送则收到 accessError|clearChat(4.4 节回显)。

5.2 播放列表域(AddVideo 等 11 事件)

AddVideo——条目载荷要求最严格的事件,实测踩坑点有两个:

  1. playerType 必须是 5 个枚举值的字面量:RawType / YoutubeType / VkType / VimeoType / IframeType。实测传字符串 "Raw"(想当然的写法)服务端直接拒收,终端日志与响应均为:
Wrong request for type "AddVideo":
line1: characters 220-225 : Variable 'playerType' should be of type 'PlayerType'
line1: characters 220-225 : Variable 'playerType' should be in the json
  1. 同 URL 不可重复添加,实测响应:
{
 "type": "ServerMessage",
 "serverMessage": {
  "textId": "videoAlreadyExistsError"
 }
}

成功添加的标准请求(直链 mp4 走 RawType,atEnd=true 追加到列表尾)与广播回显:

item = {"url": "http://192.168.1.10:8080/stream.m3u8", "title": "HLS 测试流",
        "author": "admin", "duration": 30.0, "isTemp": False,
        "doCache": False, "playerType": "RawType"}
ws.send(json.dumps({"type": "AddVideo", "addVideo": {"item": item, "atEnd": True}}))
{
 "addVideo": {
  "atEnd": true,
  "item": {
   "author": "admin",
   "doCache": false,
   "duration": 30,
   "isTemp": false,
   "playerType": "RawType",
   "title": "HLS 测试流",
   "url": "http://192.168.1.10:8080/stream.m3u8"
  }
 },
 "type": "AddVideo"
}

字段说明:duration 秒(决定自动切条时机);isTemp=true 为临时条目(播完自动移除);doCache 表示是否走服务器缓存(需缓存依赖,见配置详解 5.6 节);HLS 的 .m3u8 直链按 RawType 添加实测可正常入列。

SkipVideo {url}——对当前正在播放的条目发起跳过投票(需 removeVideo 权限)。所需票数 = ceil(在线人数 × voteSkipRatio),默认 0.51 即过半。实测单人在线(需 1 票)发起后立即生效,服务端先广播 SkipVideo 确认切条、紧随一条 VideoLoaded 通知新条目加载:

{
 "skipVideo": {
  "url": "http://192.168.1.10:8080/sample.mp4"
 },
 "type": "SkipVideo"
}
{"type": "VideoLoaded"}

票数不足时广播 SkipVideoStats {voted, total, required} 计数进度。

PlayItem {pos}(需 changeOrder 权限):直接播放指定下标条目。实测回显:

{
 "playItem": {
  "pos": 0
 },
 "type": "PlayItem"
}

SetNextItem {pos}:把指定条目挪到"下一个播"的位置。注意实测发现其空转边界——pos 等于当前位置或当前位置 +1 时服务端静默忽略(源码 src/server/Main.hx:1044 的 if (pos == videoList.pos || pos == videoList.pos + 1) return;),机器人重试时不要误判为失败。

ToggleItemType {pos}:切换条目临时/永久态,广播回显 {type: "ToggleItemType", toggleItemType: {pos: 0}};载荷带 all: true 时整表统一切换并以 UpdatePlaylist 全量回。

ShufflePlaylist:随机打乱列表,服务端以 UpdatePlaylist 全量回推(实测回显中可见 isTemp 状态随条目保留):

{
 "type": "UpdatePlaylist",
 "updatePlaylist": {
  "videoList": [
   {"author": "leader1", "duration": 30, "isTemp": true, "playerType": "RawType",
    "title": "本地测试视频", "url": "http://192.168.1.10:8080/sample.mp4"},
   {"author": "admin", "duration": 30, "isTemp": false, "playerType": "RawType",
    "title": "HLS 测试流", "url": "http://192.168.1.10:8080/stream.m3u8"}
  ]
 }
}

TogglePlaylistLock(需 lockPlaylist 权限):锁定/解锁播放列表(锁定后游客与普通用户不能增删条目)。实测两次发送的回显:

{"type": "TogglePlaylistLock", "togglePlaylistLock": {"isOpen": false}}
{"type": "TogglePlaylistLock", "togglePlaylistLock": {"isOpen": true}}

RemoveVideo {url} / ClearPlaylist:删除单条 / 清空全表,均为权限内广播(清空会同时停掉播放计时器)。

5.3 播放控制域(SetLeader / GetTime / Pause / Play / SetRate / Rewind 等)

SetLeader {clientName}:放映权交接的核心事件——clientName 写自己即申请 Leader(需 requestLeader 权限),写他人即指派(需 setLeader 权限,默认仅管理员)。实测自任 Leader 的广播回显:

{
 "type": "SetLeader",
 "setLeader": {
  "clientName": "leader1"
 }
}

GetTime:进度校准心跳。官方前端周期性发送 {type: "GetTime", getTime: {time: <本地时间>}},服务端单播回权威时钟。实测回显:

{
 "type": "GetTime",
 "getTime": {
  "time": 0,
  "paused": true,
  "rate": 1.5
 }
}

time 为服务端权威进度(秒),paused 为暂停态,rate 仅在倍速 ≠1 时出现(且无 Leader 时服务端自动把倍速重置回 1,源码 src/server/Main.hx:948)。机器人对时即以此值为基准。

Pause {time} / Play {time}:仅 Leader 生效,全员广播(本 Leader 也会收到,便于多端一致):

{"type": "Pause", "pause": {"time": 5}}
{"type": "Play", "play": {"time": 5}}

SetRate {rate}:Leader 调整倍速(0.5~2 之间安全)。实测它是"除发送者外广播"——发送后等待 2 秒无任何回显,其他客户端才会收到 {type: "SetRate", setRate: {rate: 1.5}}。单连接脚本不要等待该事件的回执。

Rewind {time}:快进/回退到绝对秒数(需 rewind 权限),全员广播。实测发送 time: 10 时服务端返回的时间为 10.000814641502075——服务端在目标值上叠加了当前已播进度的零头(时钟平滑),不要假设回显值与请求值相等;负值会被钳制到 0(源码 src/server/Main.hx:978)。

Flashback:一键回退到最近一次大跨度操作(跳条、大 seek)前的状态——服务端在 Pause/SetTime/PlayItem 等操作跨度超过 FLASHBACK_DIST 时自动存档(源码 saveFlashbackTime),Flashback 无载荷,发送即回退。对应聊天命令 /fb 的协议层。

SetTime {time}:Leader 专用,语义与 Rewind 相同(绝对时间定位),区别仅在于 SetTime 不受 rewind 权限约束而走 Leader 身份校验(源码 src/server/Main.hx:953-955),是官方前端进度条拖动使用的事件。

VideoLoaded:客户端加载完当前条目后上报(无载荷 {type: "VideoLoaded"}),服务端借它感知全员就绪并广播。实测切条后服务端也会主动下发该事件(5.2 节 SkipVideo 回显的第二条)。

5.4 连接与状态域(UpdateClients / Disconnected / Progress)

UpdateClients:客户端主动发送 {type: "UpdateClients"} 拉取在线名单,服务端广播回全员(实测回显,admin group=14 为取得放映权的管理员):

{
 "type": "UpdateClients",
 "updateClients": {
  "clients": [
   {"name": "admin", "group": 14},
   {"name": "guest1", "group": 2}
  ]
 }
}

任何人进出、封禁、授权变化都会触发服务端主动推送该事件——名单维护无需轮询,监听即可。

Disconnected:连接断开后服务端在会话日志与广播中标记离场(机器人下线时其他观众可见)。

Progress:服务器缓存下载进度推送(type 为 Caching/Downloading/Uploading/Canceled/Completed 五态 + ratio 进度),仅在使用缓存功能时出现。

5.5 管理与诊断域(KickClient / BanClient / Dump / CrashTest)

KickClient {name}(需 banClient 权限):踢人。被踢者收到裸 KickClient 事件后断线,且前端标记 disabledReconnection(官方前端禁止其自动重连)。实测:管理员对 guest1 发送后,两个连接分别收到——

管理员侧(无回执,等 2 秒无响应)与被踢者侧:

{"type": "KickClient"}

BanClient {name, time}(需 banClient 权限):按被禁者 IP 封禁,time 单位为秒(源码 src/server/Main.hx:669:currentTime + time * 1000 毫秒换算),time=0 表示解封。实测三连:

  1. 封 3 秒:管理员收到广播名单更新,guest1 的 group 变为 3(Banned=1 + User=2 的位标志实证):
{
 "type": "UpdateClients",
 "updateClients": {
  "clients": [
   {"name": "admin", "group": 14},
   {"name": "guest1", "group": 3}
  ]
 }
}
  1. 被封连接随即被服务端断开;封禁期间该 IP 新连接虽能建立但被标记 isBanned,封禁到期后自动恢复(实测 4 秒后重连 group=2 正常);
  2. 解封(time=0)同样广播 UpdateClients,无独立回执。

边界行为(源码 src/server/Main.hx:657-660):封禁自己或任何管理员返回 ServerMessage textId=adminsCannotBeBannedError。

Dump(仅管理员):全量诊断转储,实测一次请求三条响应(顺序固定):

{"type": "ServerMessage", "serverMessage": {"textId": "Free space: 3145728KiB"}}
{"type": "ServerMessage", "serverMessage": {"textId": "Memory usage: {\n\trss : 56340480, \n\theapTotal : 7499776, \n\theapUsed : 6795000, \n\texternal : 2123246, \n\tarrayBuffers : 22009\n}"}}
{"type": "Dump", "dump": {"data": "{\n\t\"state\": { ... },\n\t\"clients\": [ ... ],\n\t\"logs\": [ ... ]\n}"}}

Dump.data 是格式化 JSON 字符串,json.loads 后包含三部分:state(与 user/state.json 同构的频道状态:播放列表/聊天历史/计时器/闪回栈)、clients(含 ip/isBanned/isAdmin/isLeader 的完整在线表)、logs(本会话全部事件流水)。机器人做巡检时读前两条 ServerMessage 即可拿到磁盘与内存水位。

CrashTest(仅管理员):官方预留的崩溃自检——触发 uncaughtException 使进程退出(退出前仍会 Saving state... 保存)。生产环境不要接入任何自动化流程;它的正规用途是在 systemd Restart=on-failure 托管下演练自动拉起(见《Ubuntu 22.04 安装 SyncTube 1.0.0 指南》4.6 节)。

六、服务进程终端命令(5 条)

6.1 命令入口

服务进程从 stdin 读取 5 条斜杠命令(src/server/ConsoleInput.hx)。前台 node build/server.js 启动时直接在终端输入即可。输入未知命令时服务端回显完整命令表(实测,控制序列已清理):

Unknown command "help me". List:
/addAdmin name password | Adds channel admin
/removeAdmin name       | Removes channel admin
/replay name            | Replay log file on server from user/logs/
/logList                | Show log list from user/logs/
/exit                   | Exit process

⚠️ 注意:systemd 托管时进程没有交互终端,这 5 条命令全部不可用(等效输入 help me 的回显可在调试时验证)。停止服务用 sudo systemctl stop synctube,新增管理员走 /setup 网页流程(见《Ubuntu 22.04 安装 SyncTube 1.0.0 指南》常见问题 Q2)。

6.2 命令逐条实测

以下实测基于前台进程(启动于 /opt/synctube),输出行均照抄自服务 stdout(时间戳与控制序列清理,下同)。

/logList——列出 user/logs/ 下的会话日志(每次进程会话结束落盘一个 JSON,文件名即日志名,不含 .json 后缀):

2026-10-30_01_14_59
2026-10-30_01_20_30
2026-10-30_01_28_20

/addAdmin name password——内存中新增管理员(立即生效可登录;/exit 或 systemctl stop 时随 user/users.json 落盘):

Admin testadmin added.

参数缺失时的报错形态(实测 /addAdmin 不带参数):

Wrong count of arguments for command "addAdmin" (0 instead of 2)

/removeAdmin name——移除管理员:

Admin testadmin removed.

/replay name——把 user/logs/ 的历史日志在当前会话重放。实测两个关键行为:

  1. 参数不能带 .json 后缀(带了会被二次拼接):
File "/opt/synctube/user/logs/2026-10-30_01_28_20.json.json" not found
  1. 正确参数重放包含连接事件的日志时,实测触发服务端未捕获异常并退出进程:
uncaughtException, {
	message : Cannot read properties of null (reading 'headers'),
	stack : TypeError: Cannot read properties of null (reading 'headers')
    at server_Main.clientIp (/opt/synctube/build/server.js:4946:24)
    at server_Main.checkBan (/opt/synctube/build/server.js:5898:17)
    at server_Main.onMessage (/opt/synctube/build/server.js:5243:9)
    at timer.run (/opt/synctube/build/server.js:4990:12)
    at Timeout._onTimeout (/opt/synctube/build/server.js:2754:6)
    at listOnTimeout (node:internal/timers:581:17)
    at process.processTimers (node:internal/timers:519:7)
}
Saving state...

根因:重放历史连接事件时,重造的请求对象没有真实 socket,clientIp 读取 headers 抛 TypeError。这是提交 7b0ea58 的已知缺陷——/replay 在生产环境等同一次意外停机(systemd Restart=on-failure 会拉起,但观众全部掉线重连),仅建议在维护窗口排障时对纯聊天类日志谨慎使用,或改用 5.5 节 Dump 的 logs 字段做只读分析。

/exit——优雅退出:保存 user/users.json(含新增管理员)、user/state.json(播放列表/进度/聊天历史)与本次会话日志后进程退出:

Saving state...

6.3 终端命令与 WS 协议的职责边界

操作走终端命令走 WS 协议
新增/移除管理员/addAdmin /removeAdmin(可离线操作)无对应事件(管理口令只在 /setup 与登录校验)
排障/诊断/logList /replay(会话日志)Dump(只读、安全、可脚本化)
停止服务/exit(前台进程)无(systemd 部署用 systemctl stop)
封禁/踢人/清屏等频道操作—BanClient / KickClient / ClearChat

七、通用约定与常见问题

Q1:机器人断线后如何恢复?

websocket-client 侧没有自动重连——重连后必须把 Connected 推送当作全新初始化(昵称回到 Guest N,需要重新 Login;播放列表与进度从 Connected 重新拿),不要缓存旧会话的 clients/videoList 状态。

Q2:为什么我发的 SetRate/KickClient 收不到任何响应?

两类原因:SetRate 这类"除发送者外广播"事件本来就没有回执(5.3 节实测);管理类事件在权限不足时部分静默忽略(如 SetNextItem 的空转边界,5.2 节)。判断"成功"不要依赖每事件必有回执,而以随后的状态广播(UpdatePlaylist/UpdateClients)为准。

Q3:消息是按发送顺序返回的吗?

单连接内服务端按序处理、按序回推(TCP 保证);但广播类响应会穿插其他客户端触发的推送。实测脚本按"发一条收一条"推进时会收到错位的事件(本文取证时就遇到过 Dump 响应与 UpdateClients 穿插)——解析端务必按 type 分派而不是按序号配对。

Q4:能用 HTTP 直接调这些能力吗?

不能。除页面资源、/setup 初始化与静态文件外,服务端不提供任何 JSON over HTTP 的操作接口。集成一律走 WS;容器外的旁路(读 user/state.json)只能做只读监控,写入不生效(服务端启动加载 + 退出覆盖写)。

Q5:如何防止机器人昵称被冒充?

协议对 Login.clientName 无认证(同名后登录者会顶替名单显示)。真要防冒充,把机器人建成管理员(/addAdmin bot 密码)并走 4.3 节的 passHash 登录——管理员名有口令保护,且 adminsCannotBeBannedError 保证其不被封踢。

八、参考链接