mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
# Conflicts: # src/bot/instance.test.ts
This commit is contained in:
commit
486c3a0a69
21 files changed
+804
-34
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -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")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -115,17 +115,23 @@ 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 songs: Song[] = lines
|
||||
.slice(offset, offset + limit)
|
||||
.map((line) => {
|
||||
const entry = JSON.parse(line) as YtDlpEntry;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+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