mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9767a7471 | ||
|
|
47b29358ae | ||
|
|
cb66d77e9c | ||
|
|
9f5e27a277 | ||
|
|
486c3a0a69 | ||
|
|
8359c56dc7 | ||
|
|
a97e72ef30 | ||
|
|
a1cc0b8574 | ||
|
|
81b8953d52 |
No files matched your search
@@ -18,6 +18,7 @@
|
||||
<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/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/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
</p>
|
||||
@@ -30,7 +31,7 @@
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/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
|
||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||
@@ -344,7 +345,7 @@ sudo systemctl start tsmusicbot
|
||||
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
||||
| `!lyrics` | 显示当前歌词 |
|
||||
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
|
||||
| `!now` | 当前播放信息 |
|
||||
| `!vote` | 投票跳过当前歌曲 |
|
||||
| `!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 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||
@@ -774,9 +775,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
||||
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
||||
- **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 修复**
|
||||
|
||||
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
|
||||
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
|
||||
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
|
||||
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface QueuedSong {
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export class PlayQueue {
|
||||
|
||||
@@ -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
@@ -13,6 +13,7 @@ import {
|
||||
type ParsedCommand,
|
||||
} from "./commands.js";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
||||
@@ -148,6 +149,7 @@ export class BotInstance extends EventEmitter {
|
||||
private profileManager: BotProfileManager;
|
||||
private isFmMode = false;
|
||||
private fmProvider: MusicProvider | null = null;
|
||||
private fmRequesterName: string | undefined;
|
||||
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||
private lastSearchResults: Song[] = [];
|
||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||
@@ -520,7 +522,11 @@ export class BotInstance extends EventEmitter {
|
||||
try {
|
||||
const response = await this.executeCommand(parsed, msg);
|
||||
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) {
|
||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||
@@ -571,7 +577,8 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
async executeCommand(
|
||||
cmd: ParsedCommand,
|
||||
msg?: TS3TextMessage
|
||||
msg?: TS3TextMessage,
|
||||
requesterName = this.requesterNameFromMessage(msg),
|
||||
): Promise<string | null> {
|
||||
// Reject commands that would push audio when the bot isn't connected:
|
||||
// otherwise ffmpeg spawns and voice goes to a half-initialized or
|
||||
@@ -599,12 +606,12 @@ export class BotInstance extends EventEmitter {
|
||||
case "find":
|
||||
return this.cmdSearch(cmd);
|
||||
case "play":
|
||||
return this.cmdPlay(cmd);
|
||||
return this.cmdPlay(cmd, requesterName);
|
||||
case "add":
|
||||
return this.cmdAdd(cmd);
|
||||
return this.cmdAdd(cmd, requesterName);
|
||||
case "playnext":
|
||||
case "pn":
|
||||
return this.cmdPlayNext(cmd);
|
||||
return this.cmdPlayNext(cmd, requesterName);
|
||||
case "pause":
|
||||
return this.cmdPause();
|
||||
case "resume":
|
||||
@@ -630,13 +637,13 @@ export class BotInstance extends EventEmitter {
|
||||
case "mode":
|
||||
return this.cmdMode(cmd);
|
||||
case "playlist":
|
||||
return this.cmdPlaylist(cmd);
|
||||
return this.cmdPlaylist(cmd, requesterName);
|
||||
case "album":
|
||||
return this.cmdAlbum(cmd);
|
||||
return this.cmdAlbum(cmd, requesterName);
|
||||
case "fm":
|
||||
return this.cmdFm(cmd);
|
||||
return this.cmdFm(cmd, requesterName);
|
||||
case "artist":
|
||||
return this.cmdArtist(cmd);
|
||||
return this.cmdArtist(cmd, requesterName);
|
||||
case "vote":
|
||||
return this.cmdVote(msg);
|
||||
case "lyrics":
|
||||
@@ -664,6 +671,7 @@ export class BotInstance extends EventEmitter {
|
||||
private disableFmMode(): void {
|
||||
this.isFmMode = false;
|
||||
this.fmProvider = null;
|
||||
this.fmRequesterName = undefined;
|
||||
}
|
||||
|
||||
private getProvider(flags: Set<string>): MusicProvider {
|
||||
@@ -675,6 +683,19 @@ export class BotInstance extends EventEmitter {
|
||||
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. */
|
||||
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
|
||||
if (!this.connected) {
|
||||
@@ -785,6 +806,7 @@ export class BotInstance extends EventEmitter {
|
||||
album: song.album,
|
||||
platform: song.platform,
|
||||
coverUrl: song.coverUrl,
|
||||
requestedBy: song.requestedBy,
|
||||
});
|
||||
await this.syncProfileToSong(song);
|
||||
this.emit("stateChange");
|
||||
@@ -812,6 +834,7 @@ export class BotInstance extends EventEmitter {
|
||||
album: song.album,
|
||||
platform: song.platform,
|
||||
coverUrl: song.coverUrl,
|
||||
requestedBy: song.requestedBy,
|
||||
});
|
||||
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
||||
await this.syncProfileToSong(song);
|
||||
@@ -884,7 +907,7 @@ export class BotInstance extends EventEmitter {
|
||||
].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>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
@@ -895,7 +918,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
this.queue.add({ ...song0 });
|
||||
this.queue.add(this.withRequester(song0, requesterName));
|
||||
this.queue.play();
|
||||
|
||||
// Reset failure counter on user-initiated play
|
||||
@@ -909,14 +932,14 @@ export class BotInstance extends EventEmitter {
|
||||
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>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
const s = song!;
|
||||
|
||||
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.
|
||||
// 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()})`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||
if (error) return error;
|
||||
@@ -949,7 +972,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.queue.getCurrentIndex() < 0
|
||||
? this.queue.size()
|
||||
: this.queue.getCurrentIndex() + 1;
|
||||
this.queue.addNext({ ...s });
|
||||
this.queue.addNext(this.withRequester(s, requesterName));
|
||||
|
||||
if (wasIdle) {
|
||||
this.queue.playAt(insertedAt);
|
||||
@@ -1114,7 +1137,7 @@ export class BotInstance extends EventEmitter {
|
||||
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>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
|
||||
@@ -1159,7 +1182,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
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();
|
||||
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"}`;
|
||||
}
|
||||
|
||||
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>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
|
||||
@@ -1196,7 +1219,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
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();
|
||||
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"}`;
|
||||
}
|
||||
|
||||
private async cmdFm(cmd: ParsedCommand): Promise<string> {
|
||||
return this.startFm(this.getProvider(cmd.flags));
|
||||
private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||
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
|
||||
// offline, so the web /fm route can't wipe the queue + flip into FM mode
|
||||
// while nothing can actually play.
|
||||
@@ -1226,11 +1249,12 @@ export class BotInstance extends EventEmitter {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
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.isFmMode = true;
|
||||
this.fmProvider = provider;
|
||||
this.fmRequesterName = requesterName?.trim() || undefined;
|
||||
this.player.resetFailures();
|
||||
|
||||
const first = this.queue.play();
|
||||
@@ -1241,7 +1265,7 @@ export class BotInstance extends EventEmitter {
|
||||
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>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const result = await provider.search(cmd.args, 50);
|
||||
@@ -1262,7 +1286,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.queue.clear();
|
||||
this.disableFmMode();
|
||||
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.player.resetFailures();
|
||||
@@ -1281,7 +1305,7 @@ export class BotInstance extends EventEmitter {
|
||||
const songs = await provider.getPersonalFm();
|
||||
if (songs.length === 0) return;
|
||||
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");
|
||||
} catch (err) {
|
||||
@@ -1316,7 +1340,10 @@ export class BotInstance extends EventEmitter {
|
||||
const provider = this.getProviderFor(song.platform);
|
||||
const lyrics = await provider.getLyrics(song.id);
|
||||
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")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ describe("database", () => {
|
||||
album: "Test Album",
|
||||
platform: "netease",
|
||||
coverUrl: "https://example.com/cover.jpg",
|
||||
requestedBy: "alice",
|
||||
});
|
||||
|
||||
botDb.addPlayHistory({
|
||||
@@ -76,6 +77,7 @@ describe("database", () => {
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].songName).toBe("Another Song");
|
||||
expect(history[1].songName).toBe("Test Song");
|
||||
expect(history[1].requestedBy).toBe("alice");
|
||||
});
|
||||
|
||||
it("saves and loads bot instances", () => {
|
||||
|
||||
+11
-3
@@ -10,6 +10,7 @@ export interface PlayHistoryEntry {
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
coverUrl: string;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface PlayHistoryRecord extends PlayHistoryEntry {
|
||||
@@ -124,6 +125,12 @@ function migrateSchema(db: Database.Database): void {
|
||||
if (!userColNames.includes("role")) {
|
||||
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 {
|
||||
@@ -137,6 +144,7 @@ function initTables(db: Database.Database): void {
|
||||
album TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
coverUrl TEXT NOT NULL,
|
||||
requestedBy TEXT NOT NULL DEFAULT '',
|
||||
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -265,8 +273,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
ensureGuestUser(db);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||
VALUES (@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, @requestedBy)
|
||||
`);
|
||||
|
||||
const selectHistory = db.prepare(`
|
||||
@@ -338,7 +346,7 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
db,
|
||||
|
||||
addPlayHistory(record) {
|
||||
insertHistory.run(record);
|
||||
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
|
||||
},
|
||||
|
||||
getPlayHistory(botId, limit) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
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.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({
|
||||
search_type: "video",
|
||||
keyword: query,
|
||||
page,
|
||||
page_size: limit,
|
||||
});
|
||||
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||
|
||||
+31
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
describe("mapKugouSongs", () => {
|
||||
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
|
||||
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
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
if (!q) return { songs: [], playlists: [], albums: [] };
|
||||
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", {
|
||||
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;
|
||||
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
||||
|
||||
@@ -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", () => {
|
||||
it("accepts a long filename without dropping its extension", async () => {
|
||||
const p = new LocalMusicProvider(dir);
|
||||
|
||||
+2
-2
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
|
||||
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 songs = this.records
|
||||
.filter((r) => existsSync(r.filePath))
|
||||
.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));
|
||||
return { songs, playlists: [], albums: [] };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
|
||||
|
||||
describe("NetEase adapter", () => {
|
||||
it("parses LRC format lyrics", () => {
|
||||
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
|
||||
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([
|
||||
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", {
|
||||
params: {
|
||||
keywords: query,
|
||||
type: 1000,
|
||||
limit: 10,
|
||||
limit,
|
||||
offset,
|
||||
...this.cookieParams,
|
||||
},
|
||||
}),
|
||||
this.api.get("/cloudsearch", {
|
||||
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
||||
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface AuthStatus {
|
||||
export interface MusicProvider {
|
||||
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>;
|
||||
setQuality(quality: string): void;
|
||||
getQuality(): string;
|
||||
|
||||
+82
-2
@@ -1,5 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// 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", () => {
|
||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
|
||||
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
@@ -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 +
|
||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||
// (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;
|
||||
|
||||
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||
// 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.
|
||||
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
|
||||
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
||||
private async searchViaMusicuFcg(
|
||||
query: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
offset = 0
|
||||
): Promise<SearchResult | null> {
|
||||
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 pageNum = Math.floor(offset / limit) + 1;
|
||||
const reqData = JSON.stringify({
|
||||
req_0: {
|
||||
module: "music.search.SearchCgiService",
|
||||
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: {
|
||||
module: "music.search.SearchCgiService",
|
||||
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: {
|
||||
module: "music.search.SearchCgiService",
|
||||
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", {
|
||||
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
|
||||
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
||||
private async searchViaClientSearchCp(
|
||||
query: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
offset = 0
|
||||
): 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 = {
|
||||
w: query,
|
||||
format: "json",
|
||||
p: 1,
|
||||
p: page,
|
||||
n: Math.min(limit, 50),
|
||||
type: 0,
|
||||
cr: 1,
|
||||
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const albumParams = {
|
||||
w: query,
|
||||
format: "json",
|
||||
p: 1,
|
||||
p: page,
|
||||
n: 5,
|
||||
t: 8,
|
||||
cr: 1,
|
||||
|
||||
+12
-6
@@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider {
|
||||
readonly platform = "youtube" as const;
|
||||
private quality = "bestaudio";
|
||||
|
||||
async search(query: string, limit = 5): Promise<SearchResult> {
|
||||
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
|
||||
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([
|
||||
`ytsearch${limit}:${query}`,
|
||||
`ytsearch${total}:${query}`,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
const songs: Song[] = lines.map((line) => {
|
||||
const entry = JSON.parse(line) as YtDlpEntry;
|
||||
return entryToSong(entry);
|
||||
});
|
||||
const songs: Song[] = lines
|
||||
.slice(offset, offset + limit)
|
||||
.map((line) => {
|
||||
const entry = JSON.parse(line) as YtDlpEntry;
|
||||
return entryToSong(entry);
|
||||
});
|
||||
return { songs, playlists: [], albums: [] };
|
||||
} catch {
|
||||
return { songs: [], playlists: [], albums: [] };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/search", async (req, res) => {
|
||||
try {
|
||||
const { q, platform, limit } = req.query;
|
||||
const { q, platform, limit, offset } = req.query;
|
||||
if (!q) {
|
||||
res.status(400).json({ error: "q (query) is required" });
|
||||
return;
|
||||
@@ -96,9 +96,13 @@ export function createMusicRouter(
|
||||
return;
|
||||
}
|
||||
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(
|
||||
q as string,
|
||||
parseInt(limit as string) || 20
|
||||
parseInt(limit as string) || 20,
|
||||
parsedOffset
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
|
||||
+17
-11
@@ -53,6 +53,11 @@ export function createPlayerRouter(
|
||||
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) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
@@ -66,7 +71,7 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "Invalid command" });
|
||||
return;
|
||||
}
|
||||
const response = await bot.executeCommand(cmd);
|
||||
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||
res.json({ message: response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -82,7 +87,7 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "Invalid command" });
|
||||
return;
|
||||
}
|
||||
const response = await bot.executeCommand(cmd);
|
||||
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||
res.json({ message: response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -120,7 +125,7 @@ export function createPlayerRouter(
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
const message = await bot.startFm(provider);
|
||||
const message = await bot.startFm(provider, requesterName(req));
|
||||
res.json({
|
||||
ok:
|
||||
!message.startsWith("No FM songs") &&
|
||||
@@ -271,7 +276,7 @@ export function createPlayerRouter(
|
||||
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
|
||||
"!"
|
||||
)!;
|
||||
const response = await bot.executeCommand(cmd);
|
||||
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||
res.json({ message: response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -332,7 +337,7 @@ export function createPlayerRouter(
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
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
|
||||
// released and deleted, but an empty/failed playlist (early return above)
|
||||
@@ -418,7 +423,7 @@ export function createPlayerRouter(
|
||||
const queue = bot.getQueueManager();
|
||||
queue.clear();
|
||||
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).
|
||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||
@@ -468,7 +473,7 @@ export function createPlayerRouter(
|
||||
const queue = bot.getQueueManager();
|
||||
bot.getPlayer().stop();
|
||||
queue.clear();
|
||||
queue.add(song);
|
||||
queue.add({ ...song, requestedBy: requesterName(req) });
|
||||
queue.play();
|
||||
|
||||
bot.getPlayer().resetFailures();
|
||||
@@ -513,7 +518,7 @@ export function createPlayerRouter(
|
||||
// after natural track end without queue.clear()).
|
||||
const insertedAt =
|
||||
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||
queue.addNext(song);
|
||||
queue.addNext({ ...song, requestedBy: requesterName(req) });
|
||||
|
||||
if (wasIdle) {
|
||||
// Promote the just-added song to current and start it.
|
||||
@@ -555,7 +560,7 @@ export function createPlayerRouter(
|
||||
const queue = bot.getQueueManager();
|
||||
const insertedAt =
|
||||
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||
queue.addNext(song);
|
||||
queue.addNext({ ...song, requestedBy: requesterName(req) });
|
||||
queue.playAt(insertedAt);
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
@@ -587,7 +592,7 @@ export function createPlayerRouter(
|
||||
const body = await bot.runExclusive(async () => {
|
||||
const queue = bot.getQueueManager();
|
||||
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 (wasIdle) {
|
||||
@@ -627,7 +632,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
|
||||
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 (bot.getPlayer().getState() === "idle") {
|
||||
@@ -678,6 +683,7 @@ export function createPlayerRouter(
|
||||
coverUrl: r.coverUrl,
|
||||
platform: r.platform,
|
||||
playedAt: r.playedAt,
|
||||
requestedBy: r.requestedBy,
|
||||
}));
|
||||
res.json({ history });
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
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'"
|
||||
>{{ 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 class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string {
|
||||
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 {
|
||||
background: var(--brand-netease-15);
|
||||
color: var(--brand-netease);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Song {
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
|
||||
requestedBy?: string;
|
||||
playedAt?: string;
|
||||
}
|
||||
|
||||
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
|
||||
+134
-1
@@ -165,6 +165,13 @@
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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 SongCard from '../components/SongCard.vue';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
type TabType = 'songs' | 'albums' | 'playlists';
|
||||
|
||||
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());
|
||||
|
||||
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 allAlbums = ref<Album[]>([]);
|
||||
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 searched = 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'));
|
||||
|
||||
// ---- 分页 / 加载更多 ----
|
||||
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
|
||||
watch(selectedSource, (src) => {
|
||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||
@@ -266,12 +356,16 @@ async function doSearch() {
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
activeTab.value = 'songs';
|
||||
resetPagination();
|
||||
router.replace({ query: { q: query.value } });
|
||||
try {
|
||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||
allSongs.value = res.data.songs ?? [];
|
||||
allAlbums.value = res.data.albums ?? [];
|
||||
allPlaylists.value = res.data.playlists ?? [];
|
||||
recordInitialHasMore(allSongs.value, 'songs');
|
||||
recordInitialHasMore(allAlbums.value, 'albums');
|
||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||
} catch {
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||
} finally {
|
||||
@@ -601,6 +695,45 @@ onMounted(() => {
|
||||
.result-section {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user