Karaoke Eternal 常用命令与运维实战(核心篇)

Karaoke Eternal v2.0.2 日常运维手册:三条扫描触发路径与日志解读、用户与队列管理实测(含 socket.io 链表语义排坑)、删库到恢复的灾难演练闭环、五大排障速查与升级纪律。

Karaoke Eternal流媒体自托管Karaoke Eternal2026/10/29

Karaoke Eternal 常用命令与运维实战(核心篇)

本文是 Karaoke Eternal v2.0.2 的日常运维手册:媒体库扫描管理、用户与队列管理、备份恢复闭环(含一次完整的灾难演练)、日志排障对照表与升级流程。所有命令与回显均来自真实操作。前置知识见《Karaoke Eternal Docker Compose 部署实战》与《Karaoke Eternal 配置参数详解》,项目背景见 《Karaoke Eternal 介绍与选型指南》。

一、服务生命周期管理

运维的日常 80% 是三件事:看状态、看日志、重启。Compose 形态下对应的命令如下(以下在本机部署实例上执行,端口 8142,数据目录 /opt/kes):

# cd /opt/kes
# docker compose ps
NAME                  IMAGE                             COMMAND                  SERVICE              CREATED          STATUS          PORTS
karaoke-eternal-b2    radrootllc/karaoke-eternal:2.0.2  "docker-entrypoint.s…"   karaoke-eternal      3 minutes ago    Up 3 minutes    0.0.0.0:8142->8080/tcp, [::]:8142->8080/tcp

STATUS Up 且端口映射正确,是服务健康的第一判据。服务日志统一走 docker logs(Docker 形态不写日志文件,文件日志级别固定为 0,见配置篇 2.2 节):

# docker logs karaoke-eternal-b2 | tail -3
00:12:46.607 (Prefs)     › Rotating JWT secret key (length=64)
00:12:46.618 (server)    › Web server running at http://10.89.64.10:8080/

重启/停止/彻底删除(数据目录不受影响):

# docker compose restart karaoke-eternal
# docker compose stop
# docker compose down

一个值得了解的进程结构——服务主进程会派生独立的媒体扫描与文件监视子进程,升级排障时不要误判为异常多进程:

# ps aux | head -2
PID   USER     TIME  COMMAND
    1 node      0:00 {MainThread} node /opt/.npm-global/bin/karaoke-eternal-server -p 8080 --data /config --serverLogLevel 0 --scannerLogLevel 0

💡 技术拓展:restart: unless-stopped 与 restart: always 的差别在于前者尊重"人为停止"——docker compose stop 之后即使重启宿主机也不会自动拉起,适合维护窗口;后者无条件拉起。派对服务器建议 unless-stopped,避免"明明手动停了却在演出中途复活"。

二、媒体库扫描管理

扫描是曲库的唯一入口。v2.0.2 的扫描体系有三条触发路径和一个重要的"扫不进"判定。

2.1 三条触发路径

路径一:文件监视(watch)自动扫描。 v2.0.2 起每个媒体文件夹默认被监视,目录内容有变化即自动增量扫描——日常运维基本无需手动干预。

路径二:REST 手动触发。 管理员会话下:

# 全库扫描(所有媒体文件夹)
# curl -s -b admin.jar http://127.0.0.1:8142/kes/api/prefs/paths/scan
OK
# 单路径扫描(pathId=2)
# curl -s -b admin.jar http://127.0.0.1:8142/kes/api/prefs/path/2/scan
OK

路径三:启动时自动扫描——KES_SCAN 环境变量(值为 pathId 逗号列表或 all),实测日志:

00:02:35.701 (main[1])   › Starting media scanner process
16:02:35.880 (queue)                › path queued for scan: /mnt/karaoke
16:02:35.881 (queue)                › Starting media scan
16:02:35.882 (FileScanner)          › Searching: /mnt/karaoke

2.2 扫描日志解读

一次完整扫描的日志分三段,学会读它就掌握了曲库排障:

16:14:17 (FileScanner)  ›   => found 3 files with valid extensions [".m4a",".mp3",".mp4",".zip"]
16:14:17 (FileScanner)  › [2/2] /mnt/karaoke/星光乐队 - 夜航星.mp4
16:14:17 (FileScanner)  ›   => duration: 0:12
16:14:17 (FileScanner)  ›   => new: {"artistId":2,"artist":"星光乐队","artistNorm":"星光乐队",
                                   "songId":2,"title":"夜航星","titleNorm":"夜航星"}
16:14:17 (FileScanner)  › Scanned 2 valid media files
16:14:17 (FileScanner)  › Searching for invalid media entries
16:14:17 (FileScanner)  › Removed 0 invalid media entries
00:14:17 (Media)     › cleanup: 0 media in nonexistent paths
00:14:17 (Media)     › cleanup: 0 songs with no associated media
00:14:17 (Media)     › cleanup: 0 stars for nonexistent songs
00:14:17 (Media)     › cleanup: 0 queue items for nonexistent songs
00:14:17 (Library)   › built library cache in 0.258ms
00:14:17 (main[1])   › Media scanner process exited (0)
  • 第一段:扩展名普查(.m4a/.mp3/.mp4/.zip 为合法扩展名);
  • 第二段:逐文件解析,new: 行即从文件名解析出的歌手/标题;=> ok 表示入库;
  • 第三段:清理无效记录(Removed 0 与四条 cleanup: 0 都是健康态),最后 exited (0) 是干净退出标志。

2.3 "扫不进"判定:合法扩展名 ≠ 有效曲目

曲库里"少了歌"最常见的原因:孤立的 MP3(无 .cdg 歌词画面伴生文件)不构成完整卡拉OK曲目,扫描器静默跳过。日志特征是文件行后只有 no .cdg sidecar found 而没有 => ok:

16:02:35.898 (FileScanner)          › [1/3] /mnt/karaoke/Synth Sisters - Loop City.mp3
16:02:35.901 (FileScanner)          ›   => no .cdg sidecar found
16:02:35.901 (FileScanner)          › [2/3] /mnt/karaoke/Test Trio - Sine Wave Serenade.mp4
16:02:35.918 (FileScanner)          ›   => ok
16:02:35.853 (FileScanner)          › Scanned 2 valid media files

修复方式:补同名 .cdg、改用 zip 打包的 MP3+G,或换成带视频轨的 MP4。另一类"扫不进"是命名解析失败——文件名无法按"Artist - Title"拆分时同样不入库,治理工具是 _kes.v2.json(配置篇 3.5 节有完整实测)。

三、用户与队列管理

3.1 用户管理

系统内置四个角色:admin(管理员)、player(播放端)、standard(普通点歌用户)、guest(访客)。常用管理操作(管理员会话):

# 创建用户(注意密码字段名是 newPassword)
# curl -s -b admin.jar -X POST http://127.0.0.1:8142/kes/api/user \
    -H 'Content-Type: application/json' \
    -d '{"username":"demo","newPassword":"demo123456","newPasswordConfirm":"demo123456",
         "name":"演示用户","role":"standard","roomId":1}'
{} [HTTP 200]
# 用户列表
# curl -s -b admin.jar http://127.0.0.1:8142/kes/api/users
{"result":[2,1],"entities":{"1":{...,"name":"管理员","role":"admin",...},"2":{...,"name":"演示用户","role":"standard",...}}}
# 任意用户登录(roomId 指定加入的房间)
# curl -s -c demo.jar -X POST http://127.0.0.1:8142/kes/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"demo","password":"demo123456","roomId":1}'
{"dateCreated":1793204220,...,"isAdmin":false,"name":"演示用户","roomId":1,"userId":2,"username":"demo"}

3.2 队列操作实测

队列的增删移走 socket.io 长连接(Web 端按钮背后就是这三条指令)。双客户端实测还原完整链路:admin(userId=1)与 demo(userId=2)同时在线。

点歌(QUEUE_ADD)——admin 点 songId=6(夜航星),demo 点 songId=5(Sine Wave Serenade):

admin 发送: 421["action",{"type":"server/QUEUE_ADD","payload":{"songId":6}}]
admin 收到: 431[{"type":"server/QUEUE_ADD_SUCCESS"}]
admin 收到: 42["action",{"type":"queue/PUSH","payload":{"result":[2,3],...
demo  发送: 422["action",{"type":"server/QUEUE_ADD","payload":{"songId":5}}]
demo  收到: 432[{"type":"server/QUEUE_ADD_SUCCESS"}]
demo  收到: 42["action",{"type":"queue/PUSH","payload":{"result":[2,3,4],...

注意 demo 收到的 queue/PUSH 包含 admin 刚点的歌——队列变化实时广播给所有连线客户端,这正是"任何人点歌,全员屏幕同步刷新"的机制。数据库视角的队列是一条链表(prevQueueId 指向前一项):

[{"queueId":2,"songId":6,"userId":1,"prevQueueId":null},
 {"queueId":3,"songId":6,"userId":1,"prevQueueId":2},
 {"queueId":4,"songId":5,"userId":2,"prevQueueId":3}]

移除(QUEUE_REMOVE)——admin 移除 queueId=3:

admin 发送: 422["action",{"type":"server/QUEUE_REMOVE","payload":{"queueId":3}}]
admin 收到: 432[{"type":"server/QUEUE_REMOVE_SUCCESS"}]
admin 收到: 42["action",{"type":"queue/PUSH","payload":{"result":[2,4],...

移除后链表自动重接(queueId=4 的 prevQueueId 从 3 改为 2),无需手工修补。

移动(QUEUE_MOVE)——注意 payload 是链表语义的 {queueId, prevQueueId}(把前者插到后者之后),不是 {queueId, index}。实测用错字段会得到真实的错误回执:

admin 发送: 421["action",{"type":"server/QUEUE_MOVE","payload":{"queueId":4,"index":0}}]
admin 收到: 431[{"type":"server/QUEUE_MOVE_ERROR","error":"Error in server/QUEUE_MOVE: Provided value cannot be bound to SQLite parameter 3."}]

改用正确语义(把 queueId=4 移到 queueId=2 之后):

admin 发送: 421["action",{"type":"server/QUEUE_MOVE","payload":{"queueId":4,"prevQueueId":2}}]
admin 收到: 431[{"type":"server/QUEUE_MOVE_SUCCESS"}]

权限规则顺带说明:非管理员只能移动/移除自己点歌的队列项(服务端校验 Queue.isOwner,越权会收到 Cannot move another user's song 错误);主持人场景让 admin 账号坐镇播放端即可全权管理队列。

💡 技术拓展:socket.io 的 42["action",...] 是"事件帧",431[...] 这类 4XX 帧是对应请求的应答帧(acknowledgement)——发送方的帧号 421 与应答帧号 431 一一对应。看到 *_SUCCESS 即请求已被服务端确认;*_ERROR 的 error 字段就是原始错误消息,排障时比日志更快。

四、备份与恢复闭环(灾难演练实录)

数据库 database.sqlite3 是整个服务唯一的状态文件(曲库、用户、队列、房间、偏好全在里面),媒体文件本身只读。备份恢复策略围绕它展开。

4.1 备份:冷备最稳

停止服务后,SQLite 的 WAL(Write-Ahead Log)临时文件会被检查点合并进主库,目录里只剩一个 database.sqlite3,直接拷走即可:

# cd /opt/kes
# docker compose stop
# ls -l config/
-rw-r--r-- 1 yahui yahui 106496 Oct 28 23:50 database.sqlite3
# mkdir -p backup/config backup/media
# cp config/database.sqlite3 backup/config/
# cp media/*.mp4 media/*.mp3 backup/media/ 2>/dev/null; cp media/*.mp4 backup/media/
# docker compose up -d

💡 技术拓展:SQLite 的 WAL 模式下,写入先进入 -wal 临时文件,某一时机才合并(checkpoint)回主库。服务运行中直接拷主库可能拿到"缺尾巴"的备份;停服后拷贝能保证 -wal/-shm 消失、主库自洽——这就是"冷备最稳"的原因。若必须热备,需用 SQLite 的在线备份接口而非文件拷贝。

4.2 灾难演练:删库 → 首启 → 恢复

第一步,模拟最坏情况——数据库文件彻底丢失,服务回到首启状态:

# docker compose stop
# rm -f config/database.sqlite3*
# docker compose up -d
# docker logs karaoke-eternal | grep -E 'Running migration|Web server'
00:12:46.437 (db)      › Running migration 1: initial-schema
00:12:46.439 (db)      › Running migration 2: replaygain
00:12:46.441 (db)      › Running migration 3: queue-linked-list
00:12:46.442 (db)      › Running migration 4: paths-rooms-data
00:12:46.443 (db)      › Running migration 5: roles
00:12:46.618 (server)    › Web server running at http://10.89.64.10:8080/

迁移日志 1→5 重跑一遍,这是"全新空库"的铁证:管理员需要重新注册,所有用户、曲库、队列归零。

第二步,用备份恢复(先停服、覆盖文件、修复属主、再启动):

# docker compose stop
# cp backup/config/database.sqlite3 config/database.sqlite3
# chown -R 1000:1000 config/
# docker compose up -d

第三步,验证恢复效果。登录原管理员账号(密码与备份前一致):

# curl -s -c admin.jar -X POST http://127.0.0.1:8142/kes/api/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"admin","password":"q1w2e3r4","roomId":1}'
{"dateCreated":1793202238,...,"isAdmin":true,"name":"管理员","roomId":1,"userId":1,"username":"admin"}

再核对用户、歌曲、队列三张表:

用户:  [{"userId":1,"username":"admin","name":"管理员","role":"admin"},
        {"userId":2,"username":"demo","name":"演示用户","role":"standard"}]
歌曲:  [{"songId":1,"title":"Sine Wave Serenade"},{"songId":2,"title":"夜航星"}]
队列:  [{"queueId":1,"songId":2,"userId":1,"prevQueueId":null},
        {"queueId":2,"songId":1,"userId":2,"prevQueueId":1},
        {"queueId":3,"songId":2,"userId":1,"prevQueueId":2},
        {"queueId":4,"songId":1,"userId":2,"prevQueueId":3}]

与备份前的状态逐项一致(2 用户、2 歌曲、4 条队列链表)——灾难演练闭环完成,RPO(数据丢失量)等于最近一次备份到现在的新增数据。

4.3 恢复的头号坑:属主不对导致只读崩溃

演练中真实踩到的坑必须记录:cp 恢复的数据库文件属主是 root,而服务进程按 PUID=1000 运行,进程打不开可写库会自动降级为只读打开,然后陷入崩溃循环(restart: unless-stopped 不断拉起)。日志证据链:

16:03:32.250 (db)          › Opening database file (read-only) /config/database.sqlite3
00:03:07.864 (main[1])   › Received Error: attempt to write a readonly database
    at DatabaseWrapper.run (file:///opt/.npm-global/lib/node_modules/karaoke-eternal/build/server/lib/Database.js:32:24)
    at Media.cleanup (file:///opt/.npm-global/lib/node_modules/karaoke-eternal/build/server/Media/Media.js:95:18)

定位要点:看到 Opening database file (read-only) 就先查属主与权限,chown -R 1000:1000 config/ 修复后日志变回 Opening database file (writeable),服务即刻稳定。把 chown -R $PUID:$PGID <数据目录> 写进你的恢复预案,位置紧跟在"覆盖数据库文件"之后。

五、日志排障速查表

以下五条均来自本系列实测,按"日志关键词 → 根因 → 处置"组织:

日志关键词根因处置
Unhandled Rejection: Error: unable to open database file数据目录属主是 root,PUID 进程无法建库(首启)chown -R 1000:1000 config/,等待自动拉起
Opening database file (read-only) + attempt to write a readonly database数据库文件属主/权限错误,进程降级只读后写库崩溃同上,恢复备份后必做 chown
ENOENT: no such file or directory, scandir 'undefined' (path offline)媒体文件夹路径没有真正写入(如添加接口漏了 dir 查询参数),扫描器拿到 undefined删除该 pathId,用 POST /api/prefs/path?dir=<URL编码路径> 重新添加
no .cdg sidecar found 且无 => ok孤立 MP3 不构成卡拉OK曲目,静默跳过补 .cdg/zip 打包/换 MP4
Bind for 127.0.0.1:8140 failed: port is already allocated端口被其他服务占用ss -tlnp 找占用者,换端口

排障时先调日志级别:KES_SERVER_CONSOLE_LEVEL=5(debug)能暴露逐请求行,定位 Web 层问题;扫描问题则调 KES_SCANNER_CONSOLE_LEVEL=5 看每个文件的解析细节(配置篇 3.1 节有级别对比实测)。

六、升级操作

升级三步:备份 → 拉新版本包 → 重建。以从 v2.0.2 升级到未来版本为例(把 compose.yaml 中的版本号改成目标版本后执行):

# cd /opt/kes
# docker compose pull
[+] Pulling 1/1
 ✔ karaoke-eternal Pulled
# docker compose up -d
[+] Running 1/1
 ✔ Container karaoke-eternal  Started

升级的实质动作发生在启动阶段——数据库迁移。首启时的迁移日志(Running migration 1: initial-schema 一路到 5: roles)展示了这套机制:每个版本带上自己的增量迁移,按序执行且只执行一次。升级后重点看三处日志:迁移是否顺利跑完、Web server running at 是否出现、以及数据是否完好(登录 + 曲库数量)。

三条纪律:

  1. 升级前必做 4.1 节的冷备——迁移不可逆的概率不为零,database.sqlite3 一份在手才有回滚能力;
  2. 版本号写死在 compose.yaml(如 :2.0.2),升级是"改版本号 + pull + up -d"的主动行为,绝不用浮动标签让"升级"在你不知情时发生;
  3. 跨大版本先看官方发布说明——迁移逻辑变更、媒体文件夹行为变化(如 v2.0.0 引入的 watch 与单文件夹扫描)都可能影响你的运维脚本。

截至 2026-10-28,v2.0.2 即当前最新版本,本文的升级流程已通过"重拉同版本包 + 迁移机制验证"确认可用;待新版本发布时按上述三步执行即可。运维闭环至此完整:扫描进曲库、队列管派对、备份保底线、日志破疑案、升级走迁移——配合配置篇的参数速查表,这台卡拉OK服务器的日常运转已无盲区。