With several people sharing one bot, personal FM always followed the one
account the bot was logged in with. Each signed-in (non-guest) web user
can now scan a QR code under Settings → 账户 to link their own NetEase
account; FM they start from the WebUI then comes from their account.
- user_music_cookies table (per user + platform, dropped with the user).
- NeteaseProvider.pollQrLogin returns the cookie without storing it, so
a personal login can never replace the bot's shared account;
checkQrCodeStatus is now built on it. withCookie gives a view bound to
another account.
- /api/me/music/netease: status / qrcode / qrcode/status / unlink, acting
only on req.user. The cookie never leaves the server.
- POST /api/player/:botId/fm uses the caller's linked account for
NetEase. Songs still resolve through the shared provider when played.
TeamSpeak chat !fm keeps using the shared account: chat users are not
tied to web accounts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
install.sh still installed Node 20 (dropped in #152), ran the Debian-only
NodeSource script on yum systems, and hard-coded /usr/bin/node. The
README only mentioned install.sh, not setup.sh.
install.sh now:
- installs Node 22 LTS from the right NodeSource repo per distro and
checks the same 22.12+/24+ floor as setup.sh
- delegates npm install, mirror detection, native-binary checks and the
build to setup.sh, so the two scripts share one install path
- stops the service and replaces dist/node_modules on re-install (data/
is kept), copies bin/ (yt-dlp), and uses the real node path in the unit
README explains the difference between the two scripts and when to use
which. setup.sh's "Node.js not found" message no longer says 20+.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`!playlist` already pulled a numeric id out of a URL, but the platform
still came from flags, so a QQ link without -q was looked up on NetEase,
and a YouTube ?list= link fell through to a name search on the URL.
- Detect NetEase / QQ Music / YouTube playlist links (also inside an
app's share text and the [URL] BBCode TeamSpeak adds) and take the
platform from the link.
- Follow NetEase (163cn.tv) and QQ (c6.y.qq.com/base/fcgi-bin/u) share
short links one hop. Only those hosts are fetched.
- Document it in the README command table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the bot was moved to another channel, the channel it left kept the
now-playing description forever: updateChannelDescription always targeted
getChannelId(), which by then already reported the new channel.
Remember which channel we last wrote to. On a self clientMoved event,
clear that channel and, if a song is playing, write the description to
the new one. Stop now clears the channel we actually wrote to, so a
missed move event can't leave a stale description behind either.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
better-sqlite3 stopped publishing prebuilt binaries for Node 20's ABI
(115) in 12.10.0 - upstream, not a mirror gap:
12.8.0 / 12.9.0 115 127 131 137 141
12.10.0+ 127 137 141 147
`better-sqlite3: ^12.8.0` resolves well past that, so every Node 20
install 404'd on the CDN, fell through to the source build, and demanded
Python plus a C++ toolchain before the bot could start at all. package.json
went on claiming `^20.19.0` worked, and the README went on recommending
Node 20 as one of two blessed versions. It was not a supported
configuration in any meaningful sense - it was a trap.
So say so up front: engines, both setup scripts, and the Docker images now
require Node 22.12+ (or 24+, which still needs a source build for opus).
The version gate in setup.bat / setup.sh is kept byte-identical to the
engines range, as before.
Also copy scripts/lib/console-log.mjs into the production image. The
previous commit had check-native.mjs import it, and the Dockerfile copies
check-native.mjs in on its own for `docker exec ... npm start` - without
its dependency that preflight now dies with ERR_MODULE_NOT_FOUND.
BREAKING CHANGE: Node 20 is no longer supported. Node 22.12 LTS or newer
is required; setup refuses to run on anything older.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup.bat runs `chcp 65001` and shows binary progress on stderr. On some
Windows consoles - the reporter's Windows Server 2012 R2 above all - that
code page cannot render non-ASCII text and the OS fails the write with
EIO. process.stderr is an ordinary stream, so the EIO arrived as an
'error' event, and with no listener attached Node rethrew it as an
uncaught exception:
Error: write EIO { errno: -4070, code: 'EIO', syscall: 'write' }
at log (scripts/download-binaries.mjs:84:18)
at ensureFfmpeg (scripts/download-binaries.mjs:451:5)
Those two frames pin it exactly: line 84 is `process.stderr.write`, and
line 451 is the first log line of the whole run that contains Chinese.
The three lines before it are pure ASCII and printed fine. Nothing was
wrong with the download it was announcing - setup killed itself inside
its own progress logging and reported the native modules as unusable.
scripts/lib/console-log.mjs now wraps both streams: it listens for
'error' so the failure can never be fatal, then degrades that stream
rather than dying - first to an ASCII rendering that keeps the English
half of each bilingual line, then silent if the stream is really gone.
The streams degrade independently, so a console that gives up costs
setup.log nothing: that stdout is a redirected file. check-native.mjs
gets the same treatment, since the console that cannot print its Chinese
is exactly the one a user needs its English from.
Also report a 404 honestly. better-sqlite3 dropped its Node 20 (ABI 115)
prebuilds in 12.10.0 and @discordjs/opus 0.10.0 has none for Node 24, so
users on those majors fall through to the source build and are told to
install Python and a C++ toolchain - when switching Node major takes two
minutes. Nothing in the output said so, and the README recommended
Node 20 as if it still worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Input-side fast seek (-ss before -i) requires the HTTP server to support
Range/keyframe seeking. NetEase's CDN (music.126.net signed streams) doesn't,
so dragging the progress bar hung FFmpeg and the player force-killed it
(SIGKILL) with no audio. Moving -ss after -i decodes from the start and
discards to the target, which works on any HTTP stream; FFmpeg still fast-seeks
when the CDN supports it, so QQ keeps its instant resume.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extraction always used Matroska (.mka) because it takes essentially any
audio codec. That is right for most codecs but wrong for AAC: MP4 records
the AAC encoder priming (the ~1000 warm-up samples every AAC encoder emits)
in an edit list, and the edit list does not survive into Matroska. The
remuxed track then decodes ~23 ms longer than the source, with the priming
samples played at the head instead of discarded.
Measured on a 5s 640x480 fixture: source audio decodes to 962980 bytes of
PCM, the .mka to 967440 — 4460 bytes / ~23 ms extra, peaking at -66 dBFS.
Inaudible in practice, but it also puts the track fractionally out of step
with its own reported duration, for no reason.
Pick the container by codec instead: aac -> .m4a (keeps the edit list),
everything else -> .mka as before. If the preferred container refuses the
codec, retry into .mka before falling back to keeping the whole video. AAC
is worth the special case because mp4 / mov / m4v — what people actually
upload — almost always carry it.
Adds the strongest available test of the "lossless" claim: decode the audio
straight out of the source mp4, decode the stored extract, assert the PCM is
byte-for-byte equal. Forcing .mka fails it with exactly the 4460-byte delta.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bcrypt change-password case runs six bcryptjs rounds (one hash to create
the user, four verifies, one hash for the new password). bcryptjs is pure JS,
so it takes ~4.5s on an idle machine against vitest's 5s default — and tipped
over whenever the full suite saturated the CPU. It read as an intermittent
failure but the work is genuinely slow, not hung.
The new #149 tests spawn real ffmpeg processes, which added enough CPU
pressure to turn an occasional flake into a near-every-run failure, so fix it
rather than leave a suite that cries wolf.
Raise this one case to 20s. Suite is now stably green across repeated full
runs: 138 files / 2109 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After extracting the audio track, uploadAudio assigned the record's `size`
from the new .mka BEFORE deleting the source video:
size = statSync(extracted).size;
rmSync(filePath, { force: true }); // can throw EBUSY/EPERM on Windows
filePath = extracted;
rmSync with force:true only swallows ENOENT — a briefly locked file (exactly
what the existing scheduleRetry machinery in this file exists to handle)
throws. The catch then discards the extract and keeps playing the original
container, which is correct, but `size` had already been overwritten with the
much smaller extracted size while the whole video stayed on disk. That makes
totalBytes() under-count and lets the upload directory grow past its quota.
Commit filePath and size together, only once the source is actually gone.
Adds a regression test that partially mocks node:fs to make rmSync throw for
the source .mp4 and asserts the persisted record (index.json — `size` is not
exposed through search()/toSong) still describes the retained file. With the
old ordering it records 27894 bytes for a 104544-byte file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BotInstance loads the persisted custom avatar in its constructor and handed
it to profileManager.setCustomAvatar(). On an idle bot that method
immediately starts the three-step file transfer
(fileTransferInitUpload -> uploadFileData -> clientupdate) — but the
constructor runs long before tsClient.connect(), so TS3Client.client is
still null and the very first step throws "Not connected".
Scope of the bug: setCustomAvatar stores the buffer before attempting the
upload, and profileManager.onConnect() re-applies this.customAvatar once the
handshake completes, so the avatar itself did end up on the server. What the
premature call actually cost was a guaranteed-to-fail file transfer plus a
"Profile update failed" warning on every bot start — and every restart, since
manager.startBot() tears the instance down and reconstructs it. ("Not
connected" is not in handleFeatureError's unrecoverable list, so it never
disabled the avatar feature.)
Add loadCustomAvatar(), which only stores the buffer, and use it at the
constructor call site. onConnect() was already doing the real work, so
nothing is lost. Guard on length > 0 as well: avatarStore.write() is
delete-then-write, so a crash mid-write leaves a 0-byte file, and a 0-byte
Buffer is truthy — previously that took setCustomAvatar's else branch and
fired two more doomed calls (fileTransferDeleteFile + a clear).
setCustomAvatar keeps its immediate-apply behaviour, so editing the avatar
from the WebUI on a live bot still takes effect right away.
Reported-by: @shenmu-rua
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TeamSpeak echoes a bot's own channel/server messages back to itself.
Without filtering, a chunked reply re-entered the command path: !help's
output exceeds the ~1024-byte per-message cap, so splitTextIntoChunks
splits it, and the second chunk (which starts with "!artist ...") was
parsed as a new !artist command, loading 20 search results and starting
playback.
Drop messages whose invokerID matches the bot's own clientId at the
transport boundary, before they reach any consumer.
Fold the six merged issue fixes (#119, #122, #125, #126, #127, #128) into a
single release section and version the previous entry as v1.10.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Commands table: !save / !load [-a] / !queues (with the feature-disabled note).
- Features list + WebUI pages + 行为设置 mention the two toggles and 已存队列 page.
- Changelog entry with the honest caveats: restart resumes the current track
from its start (no seek memory); Spotify auto-resume is best-effort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>