diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index be46ff2..bb5aa77 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -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; + +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"); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 3003497..d9c8855 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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"; @@ -520,7 +521,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"); @@ -1316,7 +1321,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")}`; } diff --git a/src/bot/text-chunk.test.ts b/src/bot/text-chunk.test.ts new file mode 100644 index 0000000..d62c6bc --- /dev/null +++ b/src/bot/text-chunk.test.ts @@ -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]); + }); +}); diff --git a/src/bot/text-chunk.ts b/src/bot/text-chunk.ts new file mode 100644 index 0000000..63d1b37 --- /dev/null +++ b/src/bot/text-chunk.ts @@ -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; +} diff --git a/src/music/bilibili.test.ts b/src/music/bilibili.test.ts new file mode 100644 index 0000000..da3c4da --- /dev/null +++ b/src/music/bilibili.test.ts @@ -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) { + 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; + } + + 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"); + }); +}); diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index 1942f02..cde63c2 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider { return fixed; } - async search(query: string, limit = 20): Promise { + async search(query: string, limit = 20, offset = 0): Promise { 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", { diff --git a/src/music/kugou.test.ts b/src/music/kugou.test.ts index d421837..f4d25e7 100644 --- a/src/music/kugou.test.ts +++ b/src/music/kugou.test.ts @@ -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) { + const call = get.mock.calls[0]; + expect(call, "expected a mobile search call").toBeTruthy(); + return call[1].params as Record; + } + + 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); + }); +}); diff --git a/src/music/kugou.ts b/src/music/kugou.ts index 12db94f..4a68a26 100644 --- a/src/music/kugou.ts +++ b/src/music/kugou.ts @@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider { } // --- Search (verified live via the unsigned mobile endpoint) --------------- - async search(query: string, limit = 20): Promise { + async search(query: string, limit = 20, offset = 0): Promise { 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: [] }; diff --git a/src/music/local.test.ts b/src/music/local.test.ts index 465143c..d82e066 100644 --- a/src/music/local.test.ts +++ b/src/music/local.test.ts @@ -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); diff --git a/src/music/local.ts b/src/music/local.ts index 4d03088..cc52bbf 100644 --- a/src/music/local.ts +++ b/src/music/local.ts @@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider { return song; } - async search(query: string, limit = 20): Promise { + async search(query: string, limit = 20, offset = 0): Promise { 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: [] }; } diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index af68d94..aff9f89 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -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, 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; + } + + 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); + }); +}); diff --git a/src/music/netease.ts b/src/music/netease.ts index 3c2d564..2a9acc8 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider { return this.cookie ? { cookie: this.cookie } : {}; } - async search(query: string, limit = 20): Promise { + async search(query: string, limit = 20, offset = 0): Promise { + // /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 }, }), ]); diff --git a/src/music/provider.ts b/src/music/provider.ts index b484a57..c9f9e04 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -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; + search(query: string, limit?: number, offset?: number): Promise; getSongUrl(songId: string, quality?: string): Promise; setQuality(quality: string): void; getQuality(): string; diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts index 1d33538..eafae0c 100644 --- a/src/music/qq.test.ts +++ b/src/music/qq.test.ts @@ -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); + }); +}); diff --git a/src/music/qq.ts b/src/music/qq.ts index 329ada6..8c70382 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider { }; } - async search(query: string, limit = 20): Promise { + async search(query: string, limit = 20, offset = 0): Promise { // 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 { 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 { + // `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, diff --git a/src/music/youtube.ts b/src/music/youtube.ts index 209d057..04dee64 100644 --- a/src/music/youtube.ts +++ b/src/music/youtube.ts @@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider { readonly platform = "youtube" as const; private quality = "bestaudio"; - async search(query: string, limit = 5): Promise { + async search(query: string, limit = 5, offset = 0): Promise { 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: [] }; diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts new file mode 100644 index 0000000..4be3201 --- /dev/null +++ b/src/web/api/music.test.ts @@ -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); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 2f74234..89e58eb 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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) { diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index e70d1e5..5267ced 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -165,6 +165,13 @@ @add="store.addSong(song)" /> + +
+ +
未找到相关结果
@@ -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('songs'); const selectedSource = ref(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([]); const allAlbums = ref([]); const allPlaylists = ref([]); +// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。 +const hasMoreMap = ref>({}); +const loadingMore = ref>({ 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 = {}; + 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)); diff --git a/web/src/views/searchPagination.test.ts b/web/src/views/searchPagination.test.ts new file mode 100644 index 0000000..7181ffa --- /dev/null +++ b/web/src/views/searchPagination.test.ts @@ -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); + }); + }); +}); diff --git a/web/src/views/searchPagination.ts b/web/src/views/searchPagination.ts new file mode 100644 index 0000000..c5c7973 --- /dev/null +++ b/web/src/views/searchPagination.ts @@ -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(existing: T[], incoming: T[]): T[] { + const seen = new Set(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; +}