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,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
||||||
@@ -520,7 +521,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const response = await this.executeCommand(parsed, msg);
|
const response = await this.executeCommand(parsed, msg);
|
||||||
if (response) {
|
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) {
|
} catch (err) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
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 provider = this.getProviderFor(song.platform);
|
||||||
const lyrics = await provider.getLyrics(song.id);
|
const lyrics = await provider.getLyrics(song.id);
|
||||||
if (lyrics.length === 0) return "No lyrics available";
|
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")}`;
|
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;
|
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" | "spotify";
|
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>;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,7 +86,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;
|
||||||
@@ -96,9 +96,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) {
|
||||||
|
|||||||
+134
-1
@@ -165,6 +165,13 @@
|
|||||||
@add="store.addSong(song)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
<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 type { Song } from '../stores/player.js';
|
||||||
import SongCard from '../components/SongCard.vue';
|
import SongCard from '../components/SongCard.vue';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -197,8 +207,10 @@ function loadSource(): SearchSource {
|
|||||||
return 'netease';
|
return 'netease';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabType = 'songs' | 'albums' | 'playlists';
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
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());
|
const selectedSource = ref<SearchSource>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
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 allSongs = ref<Song[]>([]);
|
||||||
const allAlbums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const allPlaylists = ref<Playlist[]>([]);
|
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 loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
const uploading = 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'));
|
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
|
// Persist source preference
|
||||||
watch(selectedSource, (src) => {
|
watch(selectedSource, (src) => {
|
||||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||||
@@ -266,12 +356,16 @@ async function doSearch() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
|
resetPagination();
|
||||||
router.replace({ query: { q: query.value } });
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
allSongs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
allAlbums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
allPlaylists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
|
recordInitialHasMore(allSongs.value, 'songs');
|
||||||
|
recordInitialHasMore(allAlbums.value, 'albums');
|
||||||
|
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||||
} catch {
|
} catch {
|
||||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -601,6 +695,45 @@ onMounted(() => {
|
|||||||
.result-section {
|
.result-section {
|
||||||
margin-bottom: 32px;
|
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 {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
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