mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
81b8953d52
commit
a1cc0b8574
14 files changed
+319
-31
No files matched your search
@@ -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;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
await this.ensureBuvidCookie();
|
await this.ensureBuvidCookie();
|
||||||
await this.ensureWbiKeys();
|
await this.ensureWbiKeys();
|
||||||
|
// /search/type is page-based; the web pages in limit-aligned steps so
|
||||||
|
// offset is a multiple of page_size.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const signed = this.signWbi({
|
const signed = this.signWbi({
|
||||||
search_type: "video",
|
search_type: "video",
|
||||||
keyword: query,
|
keyword: query,
|
||||||
|
page,
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
});
|
});
|
||||||
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||||
|
|||||||
+31
-2
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
describe("mapKugouSongs", () => {
|
describe("mapKugouSongs", () => {
|
||||||
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
|
|||||||
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("KugouProvider.search pagination", () => {
|
||||||
|
function mockProvider() {
|
||||||
|
const p = new KugouProvider();
|
||||||
|
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
|
||||||
|
(p as any).mobileHttp = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchParams(get: ReturnType<typeof vi.fn>) {
|
||||||
|
const call = get.mock.calls[0];
|
||||||
|
expect(call, "expected a mobile search call").toBeTruthy();
|
||||||
|
return call[1].params as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
const params = searchParams(get);
|
||||||
|
expect(params.page).toBe(2);
|
||||||
|
expect(params.pagesize).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20);
|
||||||
|
expect(searchParams(get).page).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-2
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return { songs: [], playlists: [], albums: [] };
|
if (!q) return { songs: [], playlists: [], albums: [] };
|
||||||
try {
|
try {
|
||||||
|
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
|
||||||
|
// steps so offset is a multiple of pagesize.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
||||||
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 },
|
params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
|
||||||
});
|
});
|
||||||
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
||||||
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
||||||
|
|||||||
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LocalMusicProvider search pagination", () => {
|
||||||
|
it("slices [offset, offset+limit) instead of the first page", async () => {
|
||||||
|
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
|
||||||
|
seed(recs); // newest-first order preserved: a, b, c, d
|
||||||
|
const p = new LocalMusicProvider(dir);
|
||||||
|
|
||||||
|
const page1 = await p.search("", 2); // offset defaults to 0
|
||||||
|
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
|
||||||
|
|
||||||
|
const page2 = await p.search("", 2, 2);
|
||||||
|
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("LocalMusicProvider filename handling", () => {
|
describe("LocalMusicProvider filename handling", () => {
|
||||||
it("accepts a long filename without dropping its extension", async () => {
|
it("accepts a long filename without dropping its extension", async () => {
|
||||||
const p = new LocalMusicProvider(dir);
|
const p = new LocalMusicProvider(dir);
|
||||||
|
|||||||
+2
-2
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
|
|||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const songs = this.records
|
const songs = this.records
|
||||||
.filter((r) => existsSync(r.filePath))
|
.filter((r) => existsSync(r.filePath))
|
||||||
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
||||||
.slice(0, limit)
|
.slice(offset, offset + limit)
|
||||||
.map((r) => this.toSong(r));
|
.map((r) => this.toSong(r));
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
|
||||||
|
|
||||||
describe("NetEase adapter", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
|
|||||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NeteaseProvider.search pagination", () => {
|
||||||
|
function mockProvider() {
|
||||||
|
const p = new NeteaseProvider("http://x");
|
||||||
|
const get = vi.fn().mockResolvedValue({
|
||||||
|
data: { result: { songs: [], playlists: [], albums: [] } },
|
||||||
|
});
|
||||||
|
(p as any).api = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the /cloudsearch call whose params.type matches. */
|
||||||
|
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
|
||||||
|
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
|
||||||
|
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
|
||||||
|
return call![1].params as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20, 20);
|
||||||
|
|
||||||
|
// songs (type 1): offset forwarded, limit unchanged
|
||||||
|
const songs = callByType(get, 1);
|
||||||
|
expect(songs.limit).toBe(20);
|
||||||
|
expect(songs.offset).toBe(20);
|
||||||
|
|
||||||
|
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
|
||||||
|
const playlists = callByType(get, 1000);
|
||||||
|
expect(playlists.limit).toBe(20);
|
||||||
|
expect(playlists.offset).toBe(20);
|
||||||
|
|
||||||
|
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
|
||||||
|
const albums = callByType(get, 10);
|
||||||
|
expect(albums.limit).toBe(20);
|
||||||
|
expect(albums.offset).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 (backward compatible)", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20);
|
||||||
|
expect(callByType(get, 1).offset).toBe(0);
|
||||||
|
expect(callByType(get, 1000).offset).toBe(0);
|
||||||
|
expect(callByType(get, 10).offset).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
return this.cookie ? { cookie: this.cookie } : {};
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
|
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
|
||||||
|
// and albums (type 10) are all limit/offset-driven so the web can page past
|
||||||
|
// the first page (playlists/albums were previously hardcoded to limit: 10).
|
||||||
const [songRes, playlistRes, albumRes] = await Promise.all([
|
const [songRes, playlistRes, albumRes] = await Promise.all([
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: {
|
params: {
|
||||||
keywords: query,
|
keywords: query,
|
||||||
type: 1000,
|
type: 1000,
|
||||||
limit: 10,
|
limit,
|
||||||
|
offset,
|
||||||
...this.cookieParams,
|
...this.cookieParams,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export interface AuthStatus {
|
|||||||
export interface MusicProvider {
|
export interface MusicProvider {
|
||||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
|
||||||
|
|
||||||
search(query: string, limit?: number): Promise<SearchResult>;
|
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
|
||||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||||
setQuality(quality: string): void;
|
setQuality(quality: string): void;
|
||||||
getQuality(): string;
|
getQuality(): string;
|
||||||
|
|||||||
+82
-2
@@ -1,5 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
|
||||||
|
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
|
||||||
|
// and the per-instance api) share this single mock so the search test can
|
||||||
|
// inspect the outgoing params/body regardless of which client issued them.
|
||||||
|
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: { create: () => ({ get: mockGet, post: mockPost }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
|
|||||||
expect(out[0].id).toBe("");
|
expect(out[0].id).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("QQMusicProvider.search pagination", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** musicu.fcg returns one song → primary path succeeds. */
|
||||||
|
function musicuOk() {
|
||||||
|
mockGet.mockImplementation(async (url: string) => {
|
||||||
|
if (url === "/cgi-bin/musicu.fcg") {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
|
||||||
|
req_album: { data: { body: { album: { list: [] } } } },
|
||||||
|
req_playlist: { data: { body: { songlist: { list: [] } } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function musicuReqData() {
|
||||||
|
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
|
||||||
|
expect(call, "expected a musicu.fcg call").toBeTruthy();
|
||||||
|
return JSON.parse(call![1].params.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
|
||||||
|
musicuOk();
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
|
||||||
|
const d = musicuReqData();
|
||||||
|
expect(d.req_0.param.page_num).toBe(2);
|
||||||
|
expect(d.req_0.param.num_per_page).toBe(20);
|
||||||
|
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
|
||||||
|
expect(d.req_album.param.page_num).toBe(2);
|
||||||
|
expect(d.req_album.param.num_per_page).toBe(20);
|
||||||
|
expect(d.req_playlist.param.page_num).toBe(2);
|
||||||
|
expect(d.req_playlist.param.num_per_page).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
|
||||||
|
musicuOk();
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20);
|
||||||
|
const d = musicuReqData();
|
||||||
|
expect(d.req_0.param.page_num).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fallback client_search_cp sets p to the page cursor", async () => {
|
||||||
|
// musicu returns no songs → primary returns null → fallback runs.
|
||||||
|
mockGet.mockImplementation(async (url: string) => {
|
||||||
|
if (url === "/cgi-bin/musicu.fcg") {
|
||||||
|
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
|
||||||
|
}
|
||||||
|
// client_search_cp
|
||||||
|
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
|
||||||
|
});
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
|
||||||
|
const songCall = mockGet.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
|
||||||
|
);
|
||||||
|
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
|
||||||
|
expect(songCall![1].params.p).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-10
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||||
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
||||||
const primary = await this.searchViaMusicuFcg(query, limit);
|
const primary = await this.searchViaMusicuFcg(query, limit, offset);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
|
|
||||||
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||||
// no playlist support). Kept as redundancy.
|
// no playlist support). Kept as redundancy.
|
||||||
return this.searchViaClientSearchCp(query, limit);
|
return this.searchViaClientSearchCp(query, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
||||||
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
||||||
private async searchViaMusicuFcg(
|
private async searchViaMusicuFcg(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
offset = 0
|
||||||
): Promise<SearchResult | null> {
|
): Promise<SearchResult | null> {
|
||||||
try {
|
try {
|
||||||
|
// num_per_page must stay >= 10 (lower values return empty). It is now
|
||||||
|
// limit-driven for ALL three lists (albums/playlists were hardcoded to
|
||||||
|
// 10). page_num is the offset cursor; the web always requests in
|
||||||
|
// limit-aligned pages so offset is a multiple of limit.
|
||||||
const numPerPage = Math.max(10, Math.min(limit, 50));
|
const numPerPage = Math.max(10, Math.min(limit, 50));
|
||||||
|
const pageNum = Math.floor(offset / limit) + 1;
|
||||||
const reqData = JSON.stringify({
|
const reqData = JSON.stringify({
|
||||||
req_0: {
|
req_0: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, search_type: 0 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
|
||||||
},
|
},
|
||||||
req_album: {
|
req_album: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: 10, search_type: 2 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
|
||||||
},
|
},
|
||||||
req_playlist: {
|
req_playlist: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: 10, search_type: 3 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
||||||
private async searchViaClientSearchCp(
|
private async searchViaClientSearchCp(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
offset = 0
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
|
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
|
||||||
|
// offset is a multiple of limit.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const songParams = {
|
const songParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: 1,
|
p: page,
|
||||||
n: Math.min(limit, 50),
|
n: Math.min(limit, 50),
|
||||||
type: 0,
|
type: 0,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const albumParams = {
|
const albumParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: 1,
|
p: page,
|
||||||
n: 5,
|
n: 5,
|
||||||
t: 8,
|
t: 8,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
|
|||||||
@@ -115,17 +115,23 @@ export class YouTubeProvider implements MusicProvider {
|
|||||||
readonly platform = "youtube" as const;
|
readonly platform = "youtube" as const;
|
||||||
private quality = "bestaudio";
|
private quality = "bestaudio";
|
||||||
|
|
||||||
async search(query: string, limit = 5): Promise<SearchResult> {
|
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
|
||||||
try {
|
try {
|
||||||
|
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
|
||||||
|
// N results. Best-effort paginate by fetching offset+limit and slicing
|
||||||
|
// locally. (offset 0 → identical to before.)
|
||||||
|
const total = offset + limit;
|
||||||
const raw = await runYtDlp([
|
const raw = await runYtDlp([
|
||||||
`ytsearch${limit}:${query}`,
|
`ytsearch${total}:${query}`,
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
]);
|
]);
|
||||||
const lines = raw.trim().split("\n").filter(Boolean);
|
const lines = raw.trim().split("\n").filter(Boolean);
|
||||||
const songs: Song[] = lines.map((line) => {
|
const songs: Song[] = lines
|
||||||
|
.slice(offset, offset + limit)
|
||||||
|
.map((line) => {
|
||||||
const entry = JSON.parse(line) as YtDlpEntry;
|
const entry = JSON.parse(line) as YtDlpEntry;
|
||||||
return entryToSong(entry);
|
return entryToSong(entry);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/search", async (req, res) => {
|
router.get("/search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, platform, limit } = req.query;
|
const { q, platform, limit, offset } = req.query;
|
||||||
if (!q) {
|
if (!q) {
|
||||||
res.status(400).json({ error: "q (query) is required" });
|
res.status(400).json({ error: "q (query) is required" });
|
||||||
return;
|
return;
|
||||||
@@ -94,9 +94,13 @@ export function createMusicRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = getProvider(platform as string);
|
const provider = getProvider(platform as string);
|
||||||
|
// Server-side pagination: offset lets the web load past the first page.
|
||||||
|
// Clamp to >= 0 so a bad/negative value falls back to the first page.
|
||||||
|
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
|
||||||
const result = await provider.search(
|
const result = await provider.search(
|
||||||
q as string,
|
q as string,
|
||||||
parseInt(limit as string) || 20
|
parseInt(limit as string) || 20,
|
||||||
|
parsedOffset
|
||||||
);
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in new issue
Block a user