Compare commits

...
Author SHA1 Message Date
b9767a7471 Merge PR #121: show requester names in play history
Merges feature/play-history-requester (@Fa1nttt) into main.

The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).

Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.

Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.

Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.

Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:17:16 +08:00
saopig1andClaude Opus 4.8 47b29358ae docs(readme): add Spotify badge to the badge bar, marked 可选 like YouTube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:08:16 +08:00
Fa1nttt cb66d77e9c feat: show requester names in play history 2026-07-04 22:32:21 +08:00
saopig1andClaude Opus 4.8 9f5e27a277 docs(readme): document Spotify source, search pagination, and full !lyrics
Reflect the two merged PRs in the README:
- Multi-source bullet + changelog: Spotify (#112, experimental/opt-in) and
  per-source search pagination "加载更多" (#115).
- !lyrics command now shows full lyrics chunked into multiple messages (#116).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:18:34 +08:00
saopig1 486c3a0a69 Merge PR #120: full !lyrics output (#116) + web search pagination (#115)
# Conflicts:
#	src/bot/instance.test.ts
2026-07-04 15:12:26 +08:00
saopig1 8359c56dc7 Merge PR #118: optional Spotify audio source (hybrid go-librespot/Rust librespot) (#112) 2026-07-04 15:08:50 +08:00
saopig1andClaude Opus 4.8 a97e72ef30 feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:42:07 +08:00
saopig1andClaude Opus 4.8 a1cc0b8574 feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:36:54 +08:00
saopig1andClaude Opus 4.8 81b8953d52 fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:26:51 +08:00
28 changed files with 913 additions and 76 deletions

No files matched your search

+7 -3
View File
@@ -18,6 +18,7 @@
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" /> <img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" /> <img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" /> <img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p> </p>
@@ -30,7 +31,7 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索,结果标注来源 - **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -344,7 +345,7 @@ sudo systemctl start tsmusicbot
| `!fm` | 私人 FM(网易云,自动续播) | | `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) | | `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) | | `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 | | `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 | | `!move <频道名>` | 移动到指定频道 |
@@ -766,7 +767,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
### 最新版本 ### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM** **功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。 - **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。 - **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
@@ -774,9 +775,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。 - **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。 - **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。 - **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
- **#112 Spotify 音源(实验性)**:新增 Spotify 作为可选音源,默认关闭、需 Premium + 自建开发者应用(PKCE,无需 Client Secret)。采用混合 librespot 后端(Linux/Docker 用 go-librespot,Windows 用 Rust librespot),元数据走 Spotify Web API,可与现有音源混排入队。为 ToS 灰色地带的实验特性,详见 [Spotify 音源(实验性)](#spotify-音源实验性)。
- **#115 搜索结果翻页**:WebUI 搜索现按来源、按分类(歌曲 / 歌单 / 专辑)提供「加载更多」,服务端新增 `offset` 分页,不再固定只返回首页 20 首 / 10 个歌单 / 10 张专辑。
**Bug 修复** **Bug 修复**
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。 - **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。 - **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。 - **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
+1
View File
@@ -14,6 +14,7 @@ export interface QueuedSong {
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
requestedBy?: string;
} }
export class PlayQueue { export class PlayQueue {
+61
View File
@@ -911,3 +911,64 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
} }
}); });
}); });
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
it("splits a long command response into multiple sends, each under the byte cap", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
const longResponse = Array.from(
{ length: 200 },
(_, i) => `歌词 line number ${i} with some content`,
).join("\n");
ctx.executeCommand = vi.fn(async () => longResponse);
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
const calls = ctx.tsClient.sendTextMessage.mock.calls;
expect(calls.length).toBeGreaterThan(1);
for (const [chunk] of calls) {
expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900);
}
});
it("sends a short command response as a single message", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
ctx.executeCommand = vi.fn(async () => "short reply");
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply");
});
});
const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as (
this: unknown,
) => Promise<string>;
describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
it("returns ALL lyric lines, not just the first 10", async () => {
const lyricLines = Array.from({ length: 30 }, (_, i) => ({
time: i,
text: `lyric line ${i}`,
}));
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }),
};
const out = await cmdLyrics.call(ctx);
for (const l of lyricLines) {
expect(out).toContain(l.text);
}
expect(out.startsWith("Lyrics for Song:")).toBe(true);
});
it("returns 'No lyrics available' when the provider has none", async () => {
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }),
};
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
});
});
+54 -27
View File
@@ -13,6 +13,7 @@ import {
type ParsedCommand, type ParsedCommand,
} from "./commands.js"; } from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig, SpotifyConfig } from "../data/config.js"; import type { BotConfig, SpotifyConfig } from "../data/config.js";
@@ -148,6 +149,7 @@ export class BotInstance extends EventEmitter {
private profileManager: BotProfileManager; private profileManager: BotProfileManager;
private isFmMode = false; private isFmMode = false;
private fmProvider: MusicProvider | null = null; private fmProvider: MusicProvider | null = null;
private fmRequesterName: string | undefined;
/** Results of the most recent !search, for "#N" selection (issue #90). */ /** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = []; private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */ /** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
@@ -520,7 +522,11 @@ export class BotInstance extends EventEmitter {
try { try {
const response = await this.executeCommand(parsed, msg); const response = await this.executeCommand(parsed, msg);
if (response) { if (response) {
await this.tsClient.sendTextMessage(response); // A single long reply (e.g. full lyrics) would exceed TeamSpeak's
// per-message byte cap, so split it and send the chunks in order.
for (const chunk of splitTextIntoChunks(response)) {
await this.tsClient.sendTextMessage(chunk);
}
} }
} catch (err) { } catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error"); this.logger.error({ err, command: parsed.name }, "Command execution error");
@@ -571,7 +577,8 @@ export class BotInstance extends EventEmitter {
async executeCommand( async executeCommand(
cmd: ParsedCommand, cmd: ParsedCommand,
msg?: TS3TextMessage msg?: TS3TextMessage,
requesterName = this.requesterNameFromMessage(msg),
): Promise<string | null> { ): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected: // Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or // otherwise ffmpeg spawns and voice goes to a half-initialized or
@@ -599,12 +606,12 @@ export class BotInstance extends EventEmitter {
case "find": case "find":
return this.cmdSearch(cmd); return this.cmdSearch(cmd);
case "play": case "play":
return this.cmdPlay(cmd); return this.cmdPlay(cmd, requesterName);
case "add": case "add":
return this.cmdAdd(cmd); return this.cmdAdd(cmd, requesterName);
case "playnext": case "playnext":
case "pn": case "pn":
return this.cmdPlayNext(cmd); return this.cmdPlayNext(cmd, requesterName);
case "pause": case "pause":
return this.cmdPause(); return this.cmdPause();
case "resume": case "resume":
@@ -630,13 +637,13 @@ export class BotInstance extends EventEmitter {
case "mode": case "mode":
return this.cmdMode(cmd); return this.cmdMode(cmd);
case "playlist": case "playlist":
return this.cmdPlaylist(cmd); return this.cmdPlaylist(cmd, requesterName);
case "album": case "album":
return this.cmdAlbum(cmd); return this.cmdAlbum(cmd, requesterName);
case "fm": case "fm":
return this.cmdFm(cmd); return this.cmdFm(cmd, requesterName);
case "artist": case "artist":
return this.cmdArtist(cmd); return this.cmdArtist(cmd, requesterName);
case "vote": case "vote":
return this.cmdVote(msg); return this.cmdVote(msg);
case "lyrics": case "lyrics":
@@ -664,6 +671,7 @@ export class BotInstance extends EventEmitter {
private disableFmMode(): void { private disableFmMode(): void {
this.isFmMode = false; this.isFmMode = false;
this.fmProvider = null; this.fmProvider = null;
this.fmRequesterName = undefined;
} }
private getProvider(flags: Set<string>): MusicProvider { private getProvider(flags: Set<string>): MusicProvider {
@@ -675,6 +683,19 @@ export class BotInstance extends EventEmitter {
return this.neteaseProvider; return this.neteaseProvider;
} }
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
const name = msg?.invokerName?.trim();
return name || undefined;
}
private withRequester<T extends Song | QueuedSong>(
song: T,
requesterName?: string,
): T & { requestedBy?: string } {
const requestedBy = requesterName?.trim();
return requestedBy ? { ...song, requestedBy } : { ...song };
}
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */ /** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise<boolean> { async resolveAndPlay(song: QueuedSong): Promise<boolean> {
if (!this.connected) { if (!this.connected) {
@@ -785,6 +806,7 @@ export class BotInstance extends EventEmitter {
album: song.album, album: song.album,
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
}); });
await this.syncProfileToSong(song); await this.syncProfileToSong(song);
this.emit("stateChange"); this.emit("stateChange");
@@ -812,6 +834,7 @@ export class BotInstance extends EventEmitter {
album: song.album, album: song.album,
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
}); });
// Keep TeamSpeak-side profile updates on the same path for play/next/FM. // Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song); await this.syncProfileToSong(song);
@@ -884,7 +907,7 @@ export class BotInstance extends EventEmitter {
].join("\n"); ].join("\n");
} }
private async cmdPlay(cmd: ParsedCommand): Promise<string> { private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
@@ -895,7 +918,7 @@ export class BotInstance extends EventEmitter {
} }
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
this.queue.add({ ...song0 }); this.queue.add(this.withRequester(song0, requesterName));
this.queue.play(); this.queue.play();
// Reset failure counter on user-initiated play // Reset failure counter on user-initiated play
@@ -909,14 +932,14 @@ export class BotInstance extends EventEmitter {
return `Now playing: ${song0.name} - ${song0.artist}`; return `Now playing: ${song0.name} - ${song0.artist}`;
} }
private async cmdAdd(cmd: ParsedCommand): Promise<string> { private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
const s = song!; const s = song!;
const wasIdle = this.player.getState() === "idle"; const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...s }); this.queue.add(this.withRequester(s, requesterName));
// If nothing was playing, start this newly-added song immediately. // If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel // Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -933,7 +956,7 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`; return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
} }
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> { private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
@@ -949,7 +972,7 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0 this.queue.getCurrentIndex() < 0
? this.queue.size() ? this.queue.size()
: this.queue.getCurrentIndex() + 1; : this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...s }); this.queue.addNext(this.withRequester(s, requesterName));
if (wasIdle) { if (wasIdle) {
this.queue.playAt(insertedAt); this.queue.playAt(insertedAt);
@@ -1114,7 +1137,7 @@ export class BotInstance extends EventEmitter {
return `Play mode set to: ${cmd.args}`; return `Play mode set to: ${cmd.args}`;
} }
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> { private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>"; if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
@@ -1159,7 +1182,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
@@ -1168,7 +1191,7 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
private async cmdAlbum(cmd: ParsedCommand): Promise<string> { private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !album <album name or ID>"; if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
@@ -1196,7 +1219,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
@@ -1205,11 +1228,11 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
private async cmdFm(cmd: ParsedCommand): Promise<string> { private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
return this.startFm(this.getProvider(cmd.flags)); return this.startFm(this.getProvider(cmd.flags), requesterName);
} }
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> { async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when // Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode // offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play. // while nothing can actually play.
@@ -1226,11 +1249,12 @@ export class BotInstance extends EventEmitter {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
this.queue.setMode(PlayMode.Random); this.queue.setMode(PlayMode.Random);
this.isFmMode = true; this.isFmMode = true;
this.fmProvider = provider; this.fmProvider = provider;
this.fmRequesterName = requesterName?.trim() || undefined;
this.player.resetFailures(); this.player.resetFailures();
const first = this.queue.play(); const first = this.queue.play();
@@ -1241,7 +1265,7 @@ export class BotInstance extends EventEmitter {
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
} }
private async cmdArtist(cmd: ParsedCommand): Promise<string> { private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>"; if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50); const result = await provider.search(cmd.args, 50);
@@ -1262,7 +1286,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of filtered) { for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
this.queue.setMode(PlayMode.Loop); this.queue.setMode(PlayMode.Loop);
this.player.resetFailures(); this.player.resetFailures();
@@ -1281,7 +1305,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPersonalFm(); const songs = await provider.getPersonalFm();
if (songs.length === 0) return; if (songs.length === 0) return;
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, this.fmRequesterName));
} }
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled"); this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) { } catch (err) {
@@ -1316,7 +1340,10 @@ export class BotInstance extends EventEmitter {
const provider = this.getProviderFor(song.platform); const provider = this.getProviderFor(song.platform);
const lyrics = await provider.getLyrics(song.id); const lyrics = await provider.getLyrics(song.id);
if (lyrics.length === 0) return "No lyrics available"; if (lyrics.length === 0) return "No lyrics available";
const lines = lyrics.slice(0, 10).map((l) => l.text); // Include the FULL lyrics (the send path chunks them under the message
// cap). Cap only to avoid pathological spam — far above any normal song.
const MAX_LYRIC_LINES = 200;
const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text);
return `Lyrics for ${song.name}:\n${lines.join("\n")}`; return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
} }
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { splitTextIntoChunks } from "./text-chunk.js";
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
describe("splitTextIntoChunks", () => {
it("returns a single chunk for a short string", () => {
const chunks = splitTextIntoChunks("hello world", 900);
expect(chunks).toEqual(["hello world"]);
});
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 60);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(60);
}
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
expect(chunks.join("\n")).toBe(text);
});
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 150);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(150);
}
expect(chunks.join("\n")).toBe(text);
});
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
const longLine = "a".repeat(500);
const chunks = splitTextIntoChunks(longLine, 100);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(100);
}
// Content is preserved (hard-split introduces split points, not \n).
expect(chunks.join("")).toBe(longLine);
});
it("never splits a multibyte character across a hard-split boundary", () => {
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
const longLine = "歌".repeat(200);
const chunks = splitTextIntoChunks(longLine, 40);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(40);
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
expect(c.includes("�")).toBe(false);
}
expect(chunks.join("")).toBe(longLine);
});
it("preserves blank lines within a single chunk", () => {
const text = "a\n\nb";
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
*
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
* does no chunking, so a long single reply (e.g. full song lyrics) would be
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
* multibyte character is ever cut in half.
*
* Content is preserved on rejoin, modulo the split points: chunks split only on
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
* long line rejoins with `chunks.join("")`.
*
* @param text The full message text.
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
* with headroom for protocol framing/escaping).
*/
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
const chunks: string[] = [];
let current = "";
const flush = (): void => {
if (current !== "") {
chunks.push(current);
current = "";
}
};
for (const rawLine of text.split("\n")) {
const pieces =
Buffer.byteLength(rawLine, "utf8") > maxBytes
? hardSplitByBytes(rawLine, maxBytes)
: [rawLine];
for (const piece of pieces) {
const candidate = current === "" ? piece : `${current}\n${piece}`;
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
current = candidate;
} else {
// current is guaranteed non-empty here: pieces never exceed maxBytes,
// so an empty `current` always accepts the next piece above.
flush();
current = piece;
}
}
}
flush();
return chunks;
}
/**
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
* a character (iterates code points, so surrogate pairs stay intact).
*/
function hardSplitByBytes(line: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = "";
let currentBytes = 0;
for (const ch of line) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (currentBytes + chBytes > maxBytes && current !== "") {
pieces.push(current);
current = "";
currentBytes = 0;
}
current += ch;
currentBytes += chBytes;
}
if (current !== "") pieces.push(current);
return pieces;
}
+2
View File
@@ -60,6 +60,7 @@ describe("database", () => {
album: "Test Album", album: "Test Album",
platform: "netease", platform: "netease",
coverUrl: "https://example.com/cover.jpg", coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
}); });
botDb.addPlayHistory({ botDb.addPlayHistory({
@@ -76,6 +77,7 @@ describe("database", () => {
expect(history).toHaveLength(2); expect(history).toHaveLength(2);
expect(history[0].songName).toBe("Another Song"); expect(history[0].songName).toBe("Another Song");
expect(history[1].songName).toBe("Test Song"); expect(history[1].songName).toBe("Test Song");
expect(history[1].requestedBy).toBe("alice");
}); });
it("saves and loads bot instances", () => { it("saves and loads bot instances", () => {
+11 -3
View File
@@ -10,6 +10,7 @@ export interface PlayHistoryEntry {
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
coverUrl: string; coverUrl: string;
requestedBy?: string;
} }
export interface PlayHistoryRecord extends PlayHistoryEntry { export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -124,6 +125,12 @@ function migrateSchema(db: Database.Database): void {
if (!userColNames.includes("role")) { if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'"); db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
} }
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
const historyColNames = historyColumns.map((c) => c.name);
if (!historyColNames.includes("requestedBy")) {
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
}
} }
function initTables(db: Database.Database): void { function initTables(db: Database.Database): void {
@@ -137,6 +144,7 @@ function initTables(db: Database.Database): void {
album TEXT NOT NULL, album TEXT NOT NULL,
platform TEXT NOT NULL, platform TEXT NOT NULL,
coverUrl TEXT NOT NULL, coverUrl TEXT NOT NULL,
requestedBy TEXT NOT NULL DEFAULT '',
playedAt TEXT NOT NULL DEFAULT (datetime('now')) playedAt TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -265,8 +273,8 @@ export function createDatabase(dbPath: string): BotDatabase {
ensureGuestUser(db); ensureGuestUser(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl) VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
`); `);
const selectHistory = db.prepare(` const selectHistory = db.prepare(`
@@ -338,7 +346,7 @@ export function createDatabase(dbPath: string): BotDatabase {
db, db,
addPlayHistory(record) { addPlayHistory(record) {
insertHistory.run(record); insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
}, },
getPlayHistory(botId, limit) { getPlayHistory(botId, limit) {
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
const p = new BiliBiliProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
// Short-circuit the buvid + wbi bootstrap so search only issues the
// /search/type request we want to inspect.
(p as any).buvidInitialized = true;
(p as any).wbiMixinKey = "0".repeat(32);
(p as any).wbiKeyFetchedAt = Date.now();
(p as any).api = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls.find(
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
);
expect(call, "expected a /search/type call").toBeTruthy();
// signWbi stringifies every value.
return call![1].params as Record<string, string>;
}
it("adds page (offset/limit+1) alongside page_size", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe("2");
expect(params.page_size).toBe("20");
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe("1");
});
});
+5 -1
View File
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
return fixed; return fixed;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie(); await this.ensureBuvidCookie();
await this.ensureWbiKeys(); await this.ensureWbiKeys();
// /search/type is page-based; the web pages in limit-aligned steps so
// offset is a multiple of page_size.
const page = Math.floor(offset / limit) + 1;
const signed = this.signWbi({ const signed = this.signWbi({
search_type: "video", search_type: "video",
keyword: query, keyword: query,
page,
page_size: limit, page_size: limit,
}); });
const res = await this.api.get("/x/web-interface/wbi/search/type", { const res = await this.api.get("/x/web-interface/wbi/search/type", {
+31 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js"; import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => { describe("mapKugouSongs", () => {
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
expect(mapKugouPlaylists(undefined)).toEqual([]); expect(mapKugouPlaylists(undefined)).toEqual([]);
}); });
}); });
describe("KugouProvider.search pagination", () => {
function mockProvider() {
const p = new KugouProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
(p as any).mobileHttp = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls[0];
expect(call, "expected a mobile search call").toBeTruthy();
return call[1].params as Record<string, unknown>;
}
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe(2);
expect(params.pagesize).toBe(20);
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe(1);
});
});
+5 -2
View File
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
} }
// --- Search (verified live via the unsigned mobile endpoint) --------------- // --- Search (verified live via the unsigned mobile endpoint) ---------------
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim(); const q = query.trim();
if (!q) return { songs: [], playlists: [], albums: [] }; if (!q) return { songs: [], playlists: [], albums: [] };
try { try {
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
// steps so offset is a multiple of pagesize.
const page = Math.floor(offset / limit) + 1;
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", { const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 }, params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
}); });
const info = res.data?.data?.info as KugouRawSong[] | undefined; const info = res.data?.data?.info as KugouRawSong[] | undefined;
return { songs: mapKugouSongs(info), playlists: [], albums: [] }; return { songs: mapKugouSongs(info), playlists: [], albums: [] };
+14
View File
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
}); });
}); });
describe("LocalMusicProvider search pagination", () => {
it("slices [offset, offset+limit) instead of the first page", async () => {
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
seed(recs); // newest-first order preserved: a, b, c, d
const p = new LocalMusicProvider(dir);
const page1 = await p.search("", 2); // offset defaults to 0
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
const page2 = await p.search("", 2, 2);
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
});
});
describe("LocalMusicProvider filename handling", () => { describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => { it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir); const p = new LocalMusicProvider(dir);
+2 -2
View File
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
return song; return song;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const songs = this.records const songs = this.records
.filter((r) => existsSync(r.filePath)) .filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q)) .filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit) .slice(offset, offset + limit)
.map((r) => this.toSong(r)); .map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js"; import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => { describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => { it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined(); expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
}); });
}); });
describe("NeteaseProvider.search pagination", () => {
function mockProvider() {
const p = new NeteaseProvider("http://x");
const get = vi.fn().mockResolvedValue({
data: { result: { songs: [], playlists: [], albums: [] } },
});
(p as any).api = { get };
return { p, get };
}
/** Find the /cloudsearch call whose params.type matches. */
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
return call![1].params as Record<string, unknown>;
}
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20);
// songs (type 1): offset forwarded, limit unchanged
const songs = callByType(get, 1);
expect(songs.limit).toBe(20);
expect(songs.offset).toBe(20);
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
const playlists = callByType(get, 1000);
expect(playlists.limit).toBe(20);
expect(playlists.offset).toBe(20);
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
const albums = callByType(get, 10);
expect(albums.limit).toBe(20);
expect(albums.offset).toBe(20);
});
it("defaults offset to 0 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(callByType(get, 1).offset).toBe(0);
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
});
+8 -4
View File
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {}; return this.cookie ? { cookie: this.cookie } : {};
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams }, params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 10, limit,
offset,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams }, params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}), }),
]); ]);
+1 -1
View File
@@ -74,7 +74,7 @@ export interface AuthStatus {
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void; setQuality(quality: string): void;
getQuality(): string; getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
// and the per-instance api) share this single mock so the search test can
// inspect the outgoing params/body regardless of which client issued them.
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => { describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
expect(out[0].id).toBe(""); expect(out[0].id).toBe("");
}); });
}); });
describe("QQMusicProvider.search pagination", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
/** musicu.fcg returns one song → primary path succeeds. */
function musicuOk() {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
},
};
}
return { data: {} };
});
}
function musicuReqData() {
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
expect(call, "expected a musicu.fcg call").toBeTruthy();
return JSON.parse(call![1].params.data);
}
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(2);
expect(d.req_0.param.num_per_page).toBe(20);
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
expect(d.req_album.param.page_num).toBe(2);
expect(d.req_album.param.num_per_page).toBe(20);
expect(d.req_playlist.param.page_num).toBe(2);
expect(d.req_playlist.param.num_per_page).toBe(20);
});
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20);
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(1);
});
it("fallback client_search_cp sets p to the page cursor", async () => {
// musicu returns no songs → primary returns null → fallback runs.
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
}
// client_search_cp
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
});
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const songCall = mockGet.mock.calls.find(
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
);
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
});
+20 -10
View File
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values). // (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit); const primary = await this.searchViaMusicuFcg(query, limit, offset);
if (primary) return primary; if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album, // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy. // no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit); return this.searchViaClientSearchCp(query, limit, offset);
} }
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg. /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */ * 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg( private async searchViaMusicuFcg(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult | null> { ): Promise<SearchResult | null> {
try { try {
// num_per_page must stay >= 10 (lower values return empty). It is now
// limit-driven for ALL three lists (albums/playlists were hardcoded to
// 10). page_num is the offset cursor; the web always requests in
// limit-aligned pages so offset is a multiple of limit.
const numPerPage = Math.max(10, Math.min(limit, 50)); const numPerPage = Math.max(10, Math.min(limit, 50));
const pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({ const reqData = JSON.stringify({
req_0: { req_0: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
}, },
req_album: { req_album: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
}, },
req_playlist: { req_playlist: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
}, },
}); });
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp( private async searchViaClientSearchCp(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult> { ): Promise<SearchResult> {
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
// offset is a multiple of limit.
const page = Math.floor(offset / limit) + 1;
const songParams = { const songParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: Math.min(limit, 50), n: Math.min(limit, 50),
type: 0, type: 0,
cr: 1, cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = { const albumParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: 5, n: 5,
t: 8, t: 8,
cr: 1, cr: 1,
+9 -3
View File
@@ -115,17 +115,23 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const; readonly platform = "youtube" as const;
private quality = "bestaudio"; private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> { async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
try { try {
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
// N results. Best-effort paginate by fetching offset+limit and slicing
// locally. (offset 0 → identical to before.)
const total = offset + limit;
const raw = await runYtDlp([ const raw = await runYtDlp([
`ytsearch${limit}:${query}`, `ytsearch${total}:${query}`,
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--no-warnings", "--no-warnings",
"--quiet", "--quiet",
]); ]);
const lines = raw.trim().split("\n").filter(Boolean); const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => { const songs: Song[] = lines
.slice(offset, offset + limit)
.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry; const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry); return entryToSong(entry);
}); });
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { createMusicRouter } from "./music.js";
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
+6 -2
View File
@@ -86,7 +86,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
try { try {
const { q, platform, limit } = req.query; const { q, platform, limit, offset } = req.query;
if (!q) { if (!q) {
res.status(400).json({ error: "q (query) is required" }); res.status(400).json({ error: "q (query) is required" });
return; return;
@@ -96,9 +96,13 @@ export function createMusicRouter(
return; return;
} }
const provider = getProvider(platform as string); const provider = getProvider(platform as string);
// Server-side pagination: offset lets the web load past the first page.
// Clamp to >= 0 so a bad/negative value falls back to the first page.
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
const result = await provider.search( const result = await provider.search(
q as string, q as string,
parseInt(limit as string) || 20 parseInt(limit as string) || 20,
parsedOffset
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {
+17 -11
View File
@@ -53,6 +53,11 @@ export function createPlayerRouter(
res.status(403).json({ error: "本地音频播放已关闭" }); res.status(403).json({ error: "本地音频播放已关闭" });
} }
function requesterName(req: any): string {
const name = req.user?.username;
return typeof name === "string" && name.trim() ? name.trim() : "游客";
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => { router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
@@ -66,7 +71,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
return; return;
} }
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -82,7 +87,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
return; return;
} }
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -120,7 +125,7 @@ export function createPlayerRouter(
? platform ? platform
: "netease" : "netease"
); );
const message = await bot.startFm(provider); const message = await bot.startFm(provider, requesterName(req));
res.json({ res.json({
ok: ok:
!message.startsWith("No FM songs") && !message.startsWith("No FM songs") &&
@@ -271,7 +276,7 @@ export function createPlayerRouter(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(), `!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!" "!"
)!; )!;
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -332,7 +337,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
} }
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are // Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above) // released and deleted, but an empty/failed playlist (early return above)
@@ -418,7 +423,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
} }
// Sweep AFTER the queue is rebuilt (see play-playlist). // Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced"); bot.cleanupQueuedLocalSongs?.("queue_replaced");
@@ -468,7 +473,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.getPlayer().stop(); bot.getPlayer().stop();
queue.clear(); queue.clear();
queue.add(song); queue.add({ ...song, requestedBy: requesterName(req) });
queue.play(); queue.play();
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
@@ -513,7 +518,7 @@ export function createPlayerRouter(
// after natural track end without queue.clear()). // after natural track end without queue.clear()).
const insertedAt = const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song); queue.addNext({ ...song, requestedBy: requesterName(req) });
if (wasIdle) { if (wasIdle) {
// Promote the just-added song to current and start it. // Promote the just-added song to current and start it.
@@ -555,7 +560,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
const insertedAt = const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song); queue.addNext({ ...song, requestedBy: requesterName(req) });
queue.playAt(insertedAt); queue.playAt(insertedAt);
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
@@ -587,7 +592,7 @@ export function createPlayerRouter(
const body = await bot.runExclusive(async () => { const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle"; const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song); queue.add({ ...song, requestedBy: requesterName(req) });
// If nothing was playing, start this newly-added song immediately. // If nothing was playing, start this newly-added song immediately.
if (wasIdle) { if (wasIdle) {
@@ -627,7 +632,7 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
// If nothing is playing, start the first song // If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") { if (bot.getPlayer().getState() === "idle") {
@@ -678,6 +683,7 @@ export function createPlayerRouter(
coverUrl: r.coverUrl, coverUrl: r.coverUrl,
platform: r.platform, platform: r.platform,
playedAt: r.playedAt, playedAt: r.playedAt,
requestedBy: r.requestedBy,
})); }));
res.json({ history }); res.json({ history });
}); });
+25
View File
@@ -9,6 +9,11 @@
class="platform-badge" class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'" :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
<span
v-if="song.requestedBy"
class="requester-badge"
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
>{{ song.requestedBy }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string {
line-height: 1.4; line-height: 1.4;
} }
.requester-badge {
flex-shrink: 0;
max-width: 96px;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
background: var(--color-online-15);
color: var(--color-online);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-badge-guest {
background: rgba(156, 163, 175, 0.18);
color: rgba(156, 163, 175, 0.95);
}
.badge-netease { .badge-netease {
background: var(--brand-netease-15); background: var(--brand-netease-15);
color: var(--brand-netease); color: var(--brand-netease);
+2
View File
@@ -11,6 +11,8 @@ export interface Song {
duration: number; duration: number;
coverUrl: string; coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify'; platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
requestedBy?: string;
playedAt?: string;
} }
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify'; export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
+134 -1
View File
@@ -165,6 +165,13 @@
@add="store.addSong(song)" @add="store.addSong(song)"
/> />
</section> </section>
<div v-if="showLoadMore" class="load-more-wrap">
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</template> </template>
<div v-else-if="searched" class="empty">未找到相关结果</div> <div v-else-if="searched" class="empty">未找到相关结果</div>
@@ -180,6 +187,9 @@ import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js'; import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue'; import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
const PAGE_SIZE = 20;
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
return 'netease'; return 'netease';
} }
type TabType = 'songs' | 'albums' | 'playlists';
const query = ref((route.query.q as string) || ''); const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs'); const activeTab = ref<TabType>('songs');
const selectedSource = ref<SearchSource>(loadSource()); const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; } interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
@@ -207,6 +219,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
const allSongs = ref<Song[]>([]); const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]); const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
const hasMoreMap = ref<Record<string, boolean>>({});
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
const uploading = ref(false); const uploading = ref(false);
@@ -230,6 +245,81 @@ const filteredPlaylists = computed(() =>
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local')); const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// ---- 分页 / 加载更多 ----
function pageKey(type: TabType, source: string): string {
return `${type}:${source}`;
}
const currentItems = computed(() => {
if (activeTab.value === 'albums') return filteredAlbums.value;
if (activeTab.value === 'playlists') return filteredPlaylists.value;
return filteredSongs.value;
});
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
const currentHasMore = computed(
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
);
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
function resetPagination() {
hasMoreMap.value = {};
loadingMore.value = { songs: false, albums: false, playlists: false };
}
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
function setHasMore(type: TabType, source: string, returnedCount: number) {
hasMoreMap.value = {
...hasMoreMap.value,
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
};
}
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
const counts: Record<string, number> = {};
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
const next = { ...hasMoreMap.value };
for (const [source, count] of Object.entries(counts)) {
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
}
hasMoreMap.value = next;
}
async function loadMore() {
const type = activeTab.value;
const source = selectedSource.value;
if (loadingMore.value[type]) return;
if (!currentHasMore.value) return;
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
loadingMore.value = { ...loadingMore.value, [type]: true };
try {
const res = await axios.get('/api/music/search', {
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
});
if (type === 'albums') {
const incoming = (res.data.albums ?? []) as Album[];
allAlbums.value = mergeDedup(allAlbums.value, incoming);
setHasMore(type, source, incoming.length);
} else if (type === 'playlists') {
const incoming = (res.data.playlists ?? []) as Playlist[];
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
setHasMore(type, source, incoming.length);
} else {
const incoming = (res.data.songs ?? []) as Song[];
allSongs.value = mergeDedup(allSongs.value, incoming);
setHasMore(type, source, incoming.length);
}
} catch {
// 保留 hasMore 现状,允许用户重试。
} finally {
loadingMore.value = { ...loadingMore.value, [type]: false };
}
}
// Persist source preference // Persist source preference
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ } try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
@@ -266,12 +356,16 @@ async function doSearch() {
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
activeTab.value = 'songs'; activeTab.value = 'songs';
resetPagination();
router.replace({ query: { q: query.value } }); router.replace({ query: { q: query.value } });
try { try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? []; allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? []; allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? []; allPlaylists.value = res.data.playlists ?? [];
recordInitialHasMore(allSongs.value, 'songs');
recordInitialHasMore(allAlbums.value, 'albums');
recordInitialHasMore(allPlaylists.value, 'playlists');
} catch { } catch {
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally { } finally {
@@ -601,6 +695,45 @@ onMounted(() => {
.result-section { .result-section {
margin-bottom: 32px; margin-bottom: 32px;
} }
.load-more-wrap {
display: flex;
justify-content: center;
margin: 8px 0 32px;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 28px;
border-radius: var(--radius-md);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
&:hover:not(:disabled) {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.spin {
animation: load-more-spin 0.8s linear infinite;
}
}
@keyframes load-more-spin {
to { transform: rotate(360deg); }
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js";
const item = (platform: string, id: string): Keyed & { label: string } => ({
platform,
id,
label: `${platform}:${id}`,
});
describe("searchPagination helpers (#115)", () => {
describe("itemKey", () => {
it("builds a `${platform}:${id}` key", () => {
expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42");
});
it("distinguishes same id across platforms", () => {
expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" }));
});
});
describe("mergeDedup", () => {
it("appends incoming items, existing first, order preserved", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "3"), item("netease", "4")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]);
});
it("drops incoming items already present in existing", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("drops duplicates within the incoming batch", () => {
const existing = [item("netease", "1")];
const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("treats same id on different platforms as distinct", () => {
const existing = [item("netease", "1")];
const incoming = [item("qq", "1")];
const merged = mergeDedup(existing, incoming);
expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]);
});
it("does not mutate the existing array", () => {
const existing = [item("netease", "1")];
const before = existing.slice();
mergeDedup(existing, [item("netease", "2")]);
expect(existing).toEqual(before);
});
it("handles empty incoming", () => {
const existing = [item("netease", "1")];
expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]);
});
});
describe("hasMore", () => {
it("is true when a full page came back", () => {
expect(hasMore(20, 20)).toBe(true);
});
it("is false when a short page came back", () => {
expect(hasMore(7, 20)).toBe(false);
});
it("is false when nothing came back", () => {
expect(hasMore(0, 20)).toBe(false);
});
});
describe("nextOffset", () => {
it("returns the page-aligned offset for a full first page", () => {
expect(nextOffset(20, 20)).toBe(20);
});
it("returns 0 when nothing is shown yet", () => {
expect(nextOffset(0, 20)).toBe(0);
});
it("rounds up to the next page boundary after dedup drops items", () => {
// page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40.
expect(nextOffset(35, 20)).toBe(40);
});
it("stays aligned across multiple full pages", () => {
expect(nextOffset(40, 20)).toBe(40);
expect(nextOffset(60, 20)).toBe(60);
});
});
});
+46
View File
@@ -0,0 +1,46 @@
// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab.
// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts).
/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */
export interface Keyed {
id: string;
platform: string;
}
/** Stable dedup key for a result item: `${platform}:${id}`. */
export function itemKey(item: Keyed): string {
return `${item.platform}:${item.id}`;
}
/**
* Merge `incoming` into `existing`, deduped by `${platform}:${id}`.
* Order is preserved with existing items first; incoming items already present
* (or duplicated within the incoming batch) are dropped.
*/
export function mergeDedup<T extends Keyed>(existing: T[], incoming: T[]): T[] {
const seen = new Set<string>(existing.map(itemKey));
const result = existing.slice();
for (const item of incoming) {
const key = itemKey(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/**
* Whether another page might exist: a full page (=== pageSize) means keep the
* button; a short/empty page (< pageSize) means the source is exhausted.
*/
export function hasMore(returnedCount: number, pageSize: number): boolean {
return returnedCount >= pageSize;
}
/**
* Offset for the next page request. Offsets are page-aligned, so this is simply
* the number of items already shown for that source+type.
*/
export function nextOffset(currentCountForSource: number, pageSize: number): number {
return Math.ceil(currentCountForSource / pageSize) * pageSize;
}