In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).
Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
Closes#59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.
Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review feedback on PR #56:
1. setCustomAvatar(buf) now triggers applyIdleAvatar when the bot is idle
(currentSong=null OR avatarEnabled=false). Spec said this; original impl
only stored the buffer, so a fresh upload from Settings was invisible
until next stop event. Track currentSong in BotProfileManager for the
idle check.
2. onConnect drops the !avatarEnabled guard — on a fresh connect there's
no song playing yet, so the spec matrix wants the custom avatar shown
regardless of sync. Previously bots reconnected with a stale TS3
server-side avatar.
3. CustomAvatarRow: defer the initializing=false flip to nextTick so the
load-time data-url assignment's queued watcher sees initializing=true
and bails. Removes the redundant PUT-on-mount that echoed the just-
loaded bytes back to the server.
4. BotInstance avatar load wrapped in try/catch — a corrupt/locked file
no longer crashes startup; we log and continue with no custom avatar.
Tests rewritten: 10 cases covering the full behavior matrix
(idle vs playing × sync on/off × custom set/null × stop/connect).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The album detail endpoint intentionally 404s (no /api/music/album/:id/detail
route), so we fall through to the stub built from songs. The album name is
already on every song (Song.album), so use it instead of the literal "专辑".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add mapQqAlbums pure helper (exported, TDD-covered) and wire a parallel
req_album sub-request (search_type: 8) into search(), populating the
albums field of SearchResult.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add mapNeteaseAlbums pure helper and wire cloudsearch?type=10 as the third Promise.all arm in search(), replacing the hardcoded albums:[].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds AvatarUpload to the create-bot form (PUT on new bot id after POST)
and a CustomAvatarRow per-bot in the profile-toggles section (GET on
mount, PUT/DELETE on user action with initializing guard).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Thread AvatarStore from index.ts → BotManager → BotInstance so every
BotInstance reads the persisted custom avatar from disk at construction
time and hands it to BotProfileManager via setCustomAvatar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spec for issue #51: custom avatar with idle/playback precedence rules,
and surfacing albums in search results + album detail playback. Two
features grouped in one spec; will land as two separate PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.
On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".
stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.
On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".
stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's
default Lavf UA. The bot's frame loop treats an early ffmpeg exit as
trackEnd and advances the queue, which surfaced as songs auto-skipping
mid-playlist. Add a browser UA + music.163.com Referer for these URLs,
extract args into a tested buildFfmpegArgs(), and harden reconnect
flags (delay_max 5 -> 30, plus reconnect_on_network_error /
reconnect_on_http_error). Verified by A/B running ffmpeg against the
same fresh CDN URL: legacy args got 0 bytes + "End of file" reading
HTTP response; fixed args streamed the full track to completion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add GitHub Actions workflow that builds linux/amd64 + linux/arm64
images via buildx + QEMU and pushes to ghcr.io/zhangtianyao1/teamspeak-music-bot
on v*.*.* tag pushes (or manual dispatch). Switch docker-compose.yml
to pull the prebuilt image so NAS / non-build environments can deploy
without a local toolchain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two corner-case fixes for the prev-history + play-next feature:
1. cmdPrev only tried queue.prev() once. instance.playNext's auto-
advance retry-skip pushes failed songs into the same history stack,
so a single prev frequently lands on an unplayable song — returning
"Cannot play previous song" while leaving queue.currentIndex stuck
mid-failure (causing next() to skip past the actually-playing song).
Retry up to 4 times so prev finds a playable history entry, matching
the retry budget already used by playNext for auto-advance.
2. SongCard.song-actions has opacity:0 by default and is revealed via
parent :hover. Touch devices have no hover, so all three action
buttons (Play / Play Next / Add) were invisible to phone/tablet
users. Add @media (pointer: coarse) → opacity:1 to always show on
touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final review of caeef65 caught a stale-currentIndex bug in
/play-next-song and cmdPlayNext: when the player is idle but
queue.currentIndex >= 0 (natural end-of-track, or playNext gave
up after retries without queue.clear), addNext splices mid-queue
and playAt(size-1) jumps PAST the inserted song to whatever
was last. Capture the insertion slot before calling addNext and
promote that exact index instead.
Add a regression test covering the [a,b,c,d] queue with
currentIndex=1 case -- splice at 2 yields x at index 2, but
size-1 would point at d (index 4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library / Search / History / Playlist now route the third action to
store.playNextSong. Home is unchanged (it doesn't use SongCard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Button sits between Play and Add to queue. Icon is mdi:playlist-play,
title '下一首播放'. Emits 'playNext' for callers to wire up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Posts to /play-next-song, surfaces failures via the existing Toast,
and refreshes the queue panel so the inserted song appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of !play / /play-song but uses queue.addNext to splice in at
currentIndex+1 instead of clearing the queue. Idle bot still starts
the song immediately. Adds 'playnext' / 'pn' to AUDIO_COMMANDS, the
command switch, and the help text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot "add → start playing"
flow is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of 390d3fa flagged that dropping playedIndices.clear() from
playAt is an unnecessary behavior change. The two operations (push
history, reset random pool) are not in tension — restoring the clear
preserves the original 'explicit pick restarts shuffle' semantic that
Random mode users rely on, while still tracking the prev-history stack.
Also add a regression test that the 50-entry HISTORY_LIMIT cap works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In random modes, prev was just doing currentIndex-1 in the array, which
has no relationship to what the user actually played before. Add a
50-entry back-stack that's pushed by next/playAt, popped by prev, reset
on play/clear/setMode, and shifted by remove. Sequential and Loop modes
keep their old fallback for the case where history is empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inert in this commit — no callers yet. Sets up the back-stack used
by the upcoming history-aware prev rewrite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight tasks: queue history field/helper, history-aware prev with
TDD tests, addNext with TDD tests, REST + command surface, store
action, SongCard third button, view wiring, final verify + smoke.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two queue features:
- prev walks back through actual play history (50-entry stack), so
random modes navigate predictably instead of falling back to the
meaningless currentIndex-1 array walk.
- addNext inserts at currentIndex+1, exposed via new /play-next-song
endpoint, !playnext command, and a third "下一首播放" button on
SongCard. Insert path keeps playedIndices and history index refs
valid by shifting entries > currentIndex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface failures + tighten edges:
1. Toast for /play-song & /play-playlist failures. Backend now returns
{ok, message} (localized in Chinese to match the rest of the UI).
Store stashes a notification on ok=false; new Toast.vue mounted in
App.vue shows it for 3-5s then fades. Clicking the X dismisses
immediately. Sits above the player on desktop and above the mobile
tabbar on phones.
2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
returns max 30 per call; we now loop using has_more / short-page
detection up to a 300-playlist hard cap. Single-call users (typical)
exit the loop on the first iteration so no extra requests.
3. getPlayableSongIds chunking. 100 mids per request keeps URL well
under 8KB; chunk-level errors are isolated so a transient blip on
one chunk doesn't poison the whole batch. Returns null only when
every chunk failed (caller falls back to sequential retry).
4. SourceTabs single-source mode now renders a small subdued "网易云"
or "QQ" label instead of vanishing entirely, so the user always
knows which platform's data they're looking at.
5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
longer show "我的歌单 0" with an empty grid.
6. Auth state change invalidates fetchHomeData cache. Previously, a
user who logged out as account A and into account B within 5
minutes would see A's playlists. Now we always re-check auth at
the top of fetchHomeData and bypass the TTL cache when authStatus
has changed.
Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
client UDP layer kicking when no server packet arrives in 60s. It's
baked in (constant `v=6e4`) and not exposed as an option, and root
cause is server-side or network-layer behavior we can't reach from
here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small reliability fixes from re-auditing:
1. Playlist.vue used Promise.all, so a flaky /detail endpoint would
blank out the whole page even though /songs returned just fine.
Switch to allSettled and synthesize a stub playlist header from
the song list when only detail fails. User can still play the
playlist; just loses the description/cover.
2. sourceTabs.readAll: typeof null === 'object' AND typeof [] ===
'object', so a corrupted localStorage value (e.g. an array) would
be treated as a record and its missing keys would silently fall
back. Reject explicitly so the failure mode is "clean defaults"
instead of "wrong shape that almost works".
3. Settings.vue loadProfileConfig: a 200 response with empty/wrong
body would set profileConfigs[botId] to a falsy/wrong-shape value,
leaving the row stuck on "加载中..." (because the v-if uses
!profileConfigs[id]). Validate the shape; surface "响应格式异常"
so the retry link is reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small but real correctness fixes from auditing recent commits:
1. getPlayableSongIds returned an empty Set for both "endpoint failed"
and "succeeded but all unplayable" — the caller couldn't tell which.
Return Set | null now: null = error (fall through to sequential
retry), empty Set = authoritative "all unplayable" (short-circuit
to a clear message instead of wasting 20+ retries).
2. Web store: fetchHomeData unconditionally wrote lastFetchTime even
when every fetch rejected (network blip, server down). That cached
the failure for 5 minutes — user had to hard-reload to recover.
Now only commit lastFetchTime if at least one auth-status call
succeeded.
3. Settings profile section: if GET /profile failed, profileConfigs
stayed undefined and the row showed "加载中..." forever. Track a
per-bot error state and render an inline "加载失败 / 重试" link
so the user can recover without page reload.
Out of scope but documented:
- ein=29 hardcoded in fetchCollectedPlaylists (no pagination yet —
users with 30+ collected QQ playlists get truncated).
- /play-song single-failure UX (returns "Cannot play" message but
frontend ignores; needs a global toast/notification primitive).
- NetEase has no analogous batch precheck (could surface same
"click and wait silent" issue if user has region-restricted NetEase
playlists).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Many users' QQ playlists (especially collected/subscribed ones) contain
a large fraction of songs that return result=104003 (region/copyright
restricted) for the current account. Sequential retry-skip wasted time
guessing — for the user's ACG古风 collected playlist, only 3 of 115
songs are actually streamable, so a 20-retry budget had < 50% chance
of finding a hit before giving up.
Add a getPlayableSongIds(ids) method to the QQ provider that calls the
upstream /getMusicPlay with a comma-separated batch of mids — the
wrapper resolves all of them in a single upstream call (~2-3s for 100+
songs). /play-playlist now duck-types this method and, when present,
filters the playlist down to playable songs before queueing.
Response message reports "Loaded N of M songs (rest are copyright/
region restricted)" so the user sees exactly what's happening instead
of guessing why the queue is short or playback didn't start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix for "播放全部 没有反应" hooked into playNext but had
two problems:
1. Used !!queue.current() to detect success, which is always true
after queue.next() advanced — so the response message would lie
about playback starting even when all retries failed.
2. The default 3-retry budget is sized for "next song couldn't
resolve, skip it" cases. User-initiated playlist plays commonly
hit long contiguous runs of QQ 104003 songs in collected playlists
(entire ACG/古风 packs can be uniformly unstreamable in some
regions), so 3-4 attempts wasn't enough.
Make playNext return whether a song actually started, and accept a
maxRetries parameter. /play-playlist now uses 20 retries — high
enough to clear typical unplayable runs, bounded enough that fully
unstreamable playlists still surface a clear "none were playable"
message rather than hanging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>