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 6c66bd9..dc80877 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"; - 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 a80545e..340420d 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -84,7 +84,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; @@ -94,9 +94,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) {