mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
With several people sharing one bot, personal FM always followed the one account the bot was logged in with. Each signed-in (non-guest) web user can now scan a QR code under Settings → 账户 to link their own NetEase account; FM they start from the WebUI then comes from their account. - user_music_cookies table (per user + platform, dropped with the user). - NeteaseProvider.pollQrLogin returns the cookie without storing it, so a personal login can never replace the bot's shared account; checkQrCodeStatus is now built on it. withCookie gives a view bound to another account. - /api/me/music/netease: status / qrcode / qrcode/status / unlink, acting only on req.user. The cookie never leaves the server. - POST /api/player/:botId/fm uses the caller's linked account for NetEase. Songs still resolve through the shared provider when played. TeamSpeak chat !fm keeps using the shared account: chat users are not tied to web accounts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
|
||
|
||
describe("NetEase adapter", () => {
|
||
it("parses LRC format lyrics", () => {
|
||
const lrc = `[00:00.00] 作词 : 周杰伦
|
||
[00:01.00] 作曲 : 周杰伦
|
||
[00:12.50]故事的小黄花
|
||
[00:15.80]从出生那年就飘着`;
|
||
|
||
const lines = parseLyrics(lrc);
|
||
expect(lines).toHaveLength(2);
|
||
expect(lines[0].time).toBeCloseTo(12.5, 1);
|
||
expect(lines[0].text).toBe("故事的小黄花");
|
||
expect(lines[1].time).toBeCloseTo(15.8, 1);
|
||
expect(lines[1].text).toBe("从出生那年就飘着");
|
||
});
|
||
|
||
it("handles empty lyrics", () => {
|
||
const lines = parseLyrics("");
|
||
expect(lines).toHaveLength(0);
|
||
});
|
||
|
||
it("merges translation lyrics", () => {
|
||
const lrc = "[00:12.50]Hello world";
|
||
const tlyric = "[00:12.50]你好世界";
|
||
const lines = parseLyrics(lrc, tlyric);
|
||
expect(lines[0].text).toBe("Hello world");
|
||
expect(lines[0].translation).toBe("你好世界");
|
||
});
|
||
|
||
it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
|
||
const raw = [
|
||
{
|
||
id: 42,
|
||
name: "Album A",
|
||
picUrl: "https://x/p.jpg",
|
||
artists: [{ name: "Artist X" }, { name: "Featured Y" }],
|
||
size: 12,
|
||
},
|
||
{
|
||
id: 99,
|
||
name: "Album B",
|
||
picUrl: "",
|
||
artists: [],
|
||
},
|
||
];
|
||
expect(mapNeteaseAlbums(raw)).toEqual([
|
||
{ id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
|
||
{ id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
|
||
]);
|
||
});
|
||
|
||
it("mapNeteaseAlbums returns [] for empty/null input", () => {
|
||
expect(mapNeteaseAlbums([])).toEqual([]);
|
||
expect(mapNeteaseAlbums(null as any)).toEqual([]);
|
||
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
|
||
});
|
||
|
||
it("mapNeteaseSongs maps fee to vip flag (1/4 = vip, 0/8 = free)", () => {
|
||
const raw = [
|
||
{ id: 1, name: "VIP", ar: [{ name: "A" }], al: { name: "Al", picUrl: "p" }, dt: 180000, fee: 1 },
|
||
{ id: 2, name: "Album-only", ar: [], al: { name: "Al", picUrl: "" }, dt: 0, fee: 4 },
|
||
{ id: 3, name: "Free", ar: [], al: {}, dt: 0, fee: 0 },
|
||
{ id: 4, name: "Free low-quality", ar: [], al: {}, dt: 0, fee: 8 },
|
||
];
|
||
const out = mapNeteaseSongs(raw);
|
||
expect(out[0].vip).toBe(true);
|
||
expect(out[1].vip).toBe(true);
|
||
expect(out[2].vip).toBe(false);
|
||
expect(out[3].vip).toBe(false); // fee=8 plays in full (low quality), NOT vip
|
||
});
|
||
|
||
it("mapNeteaseSongs accepts artists/album/duration aliases (personal_fm shape)", () => {
|
||
const out = mapNeteaseSongs([
|
||
{ id: 9, name: "FM", artists: [{ name: "B" }], album: { name: "Al2", picUrl: "p2" }, duration: 200000, fee: 0 },
|
||
]);
|
||
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
|
||
});
|
||
|
||
it("parseNeteaseTrial maps freeTrialInfo to trial seconds", () => {
|
||
// 无试听(VIP/免费)
|
||
expect(parseNeteaseTrial({})).toBeUndefined();
|
||
expect(parseNeteaseTrial({ freeTrialInfo: null })).toBeUndefined();
|
||
// 标准秒
|
||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30 } })).toBe(30);
|
||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 5, end: 35 } })).toBe(30);
|
||
// 别名容忍 begin/trialBegin
|
||
expect(parseNeteaseTrial({ freeTrialInfo: { begin: 0, end: 30 } })).toBe(30);
|
||
// 毫秒兜底(end>1000)
|
||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30000 } })).toBe(30);
|
||
// 异常 end<=start
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe("NeteaseProvider per-user login (#164)", () => {
|
||
function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) {
|
||
const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) }));
|
||
(p as any).api = { get, defaults: { baseURL: "http://127.0.0.1:3001" } };
|
||
return get;
|
||
}
|
||
|
||
it("pollQrLogin returns the cookie without touching the shared account", async () => {
|
||
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||
p.setCookie("MUSIC_U=shared");
|
||
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=personal" }));
|
||
expect(await p.pollQrLogin("k")).toEqual({ status: "confirmed", cookie: "MUSIC_U=personal" });
|
||
expect(p.getCookie()).toBe("MUSIC_U=shared");
|
||
});
|
||
|
||
it("pollQrLogin maps the waiting / scanned / expired codes", async () => {
|
||
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||
let code = 801;
|
||
withGet(p, () => ({ code }));
|
||
expect(await p.pollQrLogin("k")).toEqual({ status: "waiting" });
|
||
code = 802;
|
||
expect(await p.pollQrLogin("k")).toEqual({ status: "scanned" });
|
||
code = 800;
|
||
expect(await p.pollQrLogin("k")).toEqual({ status: "expired" });
|
||
});
|
||
|
||
it("checkQrCodeStatus still stores the cookie on the shared provider (admin login)", async () => {
|
||
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=admin" }));
|
||
expect(await p.checkQrCodeStatus("k")).toBe("confirmed");
|
||
expect(p.getCookie()).toBe("MUSIC_U=admin");
|
||
});
|
||
|
||
it("withCookie gives a view that fetches FM with the other account's cookie", async () => {
|
||
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||
p.setCookie("MUSIC_U=shared");
|
||
const personal = p.withCookie("MUSIC_U=personal");
|
||
const get = withGet(personal, () => ({ data: [] }));
|
||
await personal.getPersonalFm();
|
||
expect(get.mock.calls[0][1].params.cookie).toBe("MUSIC_U=personal");
|
||
expect(p.getCookie()).toBe("MUSIC_U=shared");
|
||
expect(personal.platform).toBe("netease");
|
||
});
|
||
});
|