Karaoke Eternal 常用命令与运维实战(核心篇)
Karaoke Eternal v2.0.2 日常运维手册:三条扫描触发路径与日志解读、用户与队列管理实测(含 socket.io 链表语义排坑)、删库到恢复的灾难演练闭环、五大排障速查与升级纪律。
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 是否出现、以及数据是否完好(登录 + 曲库数量)。
三条纪律:
- 升级前必做 4.1 节的冷备——迁移不可逆的概率不为零,
database.sqlite3一份在手才有回滚能力; - 版本号写死在
compose.yaml(如:2.0.2),升级是"改版本号 + pull + up -d"的主动行为,绝不用浮动标签让"升级"在你不知情时发生; - 跨大版本先看官方发布说明——迁移逻辑变更、媒体文件夹行为变化(如 v2.0.0 引入的 watch 与单文件夹扫描)都可能影响你的运维脚本。
截至 2026-10-28,v2.0.2 即当前最新版本,本文的升级流程已通过"重拉同版本包 + 迁移机制验证"确认可用;待新版本发布时按上述三步执行即可。运维闭环至此完整:扫描进曲库、队列管派对、备份保底线、日志破疑案、升级走迁移——配合配置篇的参数速查表,这台卡拉OK服务器的日常运转已无盲区。