Merge remote-tracking branch 'origin/main'

This commit is contained in:
senlinjun committed 2026-09-29 21:52:14 +08:00
commit e4eea8276a
26 files changed
+1921 -67

No files matched your search

+119
View File
@@ -1538,3 +1538,122 @@ describe("BotInstance live-queue persistence (#119)", () => {
}
});
});
describe("BotInstance Bilibili multi-P resolution", () => {
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
const multiPSongDetail = {
id: "BV1multiP?p=1",
name: "测试视频 - P1 分P1",
artist: "UP主",
album: "",
duration: 100, // P1 duration
coverUrl: "",
platform: "bilibili" as const,
};
const mockBili = {
platform: "bilibili" as const,
search: vi.fn().mockResolvedValue({
songs: [{
id: "BV1multiP",
name: "测试视频",
artist: "UP主",
album: "",
duration: 300, // total duration in search
coverUrl: "",
platform: "bilibili",
}],
albums: [],
playlists: [],
}),
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
};
const ctx = {
config: { commandPrefix: "!" },
lastSearchResults: [] as any[],
getProvider: () => mockBili,
getProviderFor: () => mockBili,
};
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
name: "play",
args: "测试视频",
rawArgs: ["测试视频"],
flags: new Set(),
});
expect(res.song).toBeDefined();
expect(res.song.id).toBe("BV1multiP?p=1");
expect(res.song.name).toBe("测试视频 - P1 分P1");
expect(res.song.duration).toBe(100);
});
});
describe("cmdPlaylist with a playlist link (#160)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
) => Promise<string>;
function makeCtx() {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
const makeProvider = (platform: string) => ({
platform,
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
};
const queued: any[] = [];
return {
providers,
queued,
getProvider: vi.fn(() => providers.netease),
getProviderFor: vi.fn((p: string) => providers[p]),
assertProviderEnabled: vi.fn(),
extractId: (BotInstance.prototype as any).extractId,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
player: { stop: vi.fn() },
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
disableFmMode: vi.fn(),
withRequester: (s: any) => s,
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
};
}
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
const ctx = makeCtx();
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
expect(ctx.queued[0].platform).toBe("qq");
expect(reply).toMatch(/^Loaded 1 songs/);
});
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
});
it("checks the link's platform is enabled", async () => {
const ctx = makeCtx();
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
});
it("keeps the old behavior for a bare id", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282"));
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
});
});
+51 -7
View File
@@ -13,7 +13,13 @@ import {
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import {
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
@@ -456,11 +462,16 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number }) => {
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
this.voiceDucking.reset(false);
// Carry the now-playing channel description over to the new
// channel instead of leaving it stale in the old one (#159).
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
this.logger.warn({ err }, "Channel description move update failed");
});
} else {
this.voiceDucking.removeSpeaker(event.id);
}
@@ -968,6 +979,14 @@ export class BotInstance extends EventEmitter {
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
const detail = await provider.getSongDetail(song.id);
if (detail) {
song.duration = detail.duration;
song.name = detail.name;
song.id = detail.id;
}
}
const result = await provider.getSongUrl(song.id);
if (!result?.url) {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
@@ -1134,7 +1153,12 @@ export class BotInstance extends EventEmitter {
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
let song = this.lastSearchResults[sel - 1];
if (song.platform === "bilibili") {
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
if (detail) song = { ...detail, platform: "bilibili" };
}
return { song };
}
// 2) id/URL — fetch that exact song.
@@ -1151,7 +1175,12 @@ export class BotInstance extends EventEmitter {
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
let song = result.songs[0];
if (provider.platform === "bilibili") {
const detail = await provider.getSongDetail(song.id);
if (detail) song = detail;
}
return { song: { ...song, platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
@@ -1495,8 +1524,21 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args);
@@ -1504,7 +1546,9 @@ export class BotInstance extends EventEmitter {
let playlistId: string;
if (isDirectId || id !== cmd.args) {
if (ref) {
playlistId = ref.id;
} else if (isDirectId || id !== cmd.args) {
// Input is a direct ID or URL containing an ID — use existing logic
playlistId = id;
} else {
+62
View File
@@ -202,3 +202,65 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
describe("BotProfileManager channel description follows the bot (#159)", () => {
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
let channelEdits: () => string[];
beforeEach(() => {
ts = makeMockTs() as any;
ts.cid = 5n;
(ts as any).getChannelId = () => ts.cid;
channelEdits = () =>
(ts.sendCommandNoWait as any).mock.calls
.map((c: any[]) => c[0] as string)
.filter((cmd: string) => cmd.startsWith("channeledit"));
});
it("clears the old channel and fills the new one when moved while playing", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
expect(channelEdits()).toEqual([
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
]);
ts.cid = 9n;
await pm.onChannelMoved(9n);
const edits = channelEdits();
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
});
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
await pm.onSongChange(null);
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
});
it("a move while idle touches no channel description", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("a move is ignored when the channel description feature is off", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("an event for the channel the description is already in is a no-op", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
await pm.onChannelMoved(5n);
expect(channelEdits()).toHaveLength(1);
});
});
+47 -5
View File
@@ -32,6 +32,13 @@ export class BotProfileManager {
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/**
* Channel whose description currently holds our now-playing text, or null
* if we have not written one. Remembered so that when the bot is moved we
* can still clean up the channel it was taken out of (#159) — by then
* getChannelId() already reports the new channel.
*/
private channelDescCid: bigint | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
@@ -134,6 +141,8 @@ export class BotProfileManager {
onConnect(): void {
this.generation++;
this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = {
avatar: false,
description: false,
@@ -150,6 +159,31 @@ export class BotProfileManager {
}
}
/**
* Called when the bot itself has been moved to another channel (#159).
* Clears the now-playing text from the channel it left and, if a song is
* playing, writes it to the channel it is in now.
*/
async onChannelMoved(newChannelId: bigint): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
const oldChannelId = this.channelDescCid;
if (oldChannelId === newChannelId) return;
try {
if (oldChannelId !== null) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${oldChannelId} channel_description=`,
);
this.channelDescCid = null;
}
} catch (err) {
this.handleFeatureError("channelDesc", err);
return;
}
if (this.currentSong) {
await this.updateChannelDescription(this.currentSong, newChannelId);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
@@ -400,18 +434,25 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
private async updateChannelDescription(
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
const channelId = targetChannelId ?? this.tsClient.getChannelId();
if (!song) {
// Prefer the channel we actually wrote to, in case a move event
// was missed and the bot is somewhere else now.
const target = this.channelDescCid ?? channelId;
if (target === 0n) return; // unknown channel
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
`channeledit cid=${target} channel_description=`,
);
this.channelDescCid = null;
return;
}
if (channelId === 0n) return; // unknown channel
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -422,6 +463,7 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
+63 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
@@ -120,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
expect(parseSelectionIndex("")).toBeNull();
});
});
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
expect(parsePlaylistRef("华语经典")).toBeNull();
expect(parsePlaylistRef("2829883282")).toBeNull();
expect(parsePlaylistRef("")).toBeNull();
});
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
});
it("does not mistake a NetEase userid= for the playlist id", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
});
it("parses QQ Music playlist URLs", () => {
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
});
it("parses YouTube playlist URLs by their list= id", () => {
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
});
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
});
it("finds the link inside an app's share text", () => {
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
});
});
describe("findShareShortLink (#160)", () => {
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
});
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
expect(findShareShortLink("华语经典")).toBeNull();
});
});
describe("resolveShareLink (#160)", () => {
it("returns the redirect target", async () => {
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
});
it("returns null when there is no redirect or the request fails", async () => {
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
});
});
+81
View File
@@ -1,3 +1,5 @@
import axios from "axios";
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
@@ -98,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
export interface PlaylistRef {
id: string;
platform: "netease" | "qq" | "youtube";
}
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
function stripUrlBBCode(text: string): string {
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
}
/**
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
* share text. The platform comes from the URL, so a QQ link works without
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
* the caller handles as before.
*/
export function parsePlaylistRef(raw: string): PlaylistRef | null {
const q = stripUrlBBCode(raw ?? "").trim();
if (!q) return null;
if (/music\.163\.com/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
if (/y\.qq\.com/i.test(q)) {
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
if (/youtube\.com|youtu\.be/i.test(q)) {
const m = /[?&]list=([\w-]+)/.exec(q);
if (m) return { id: m[1], platform: "youtube" };
}
return null;
}
/**
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
* short link — what the phone apps copy. Only these hosts are recognized so
* the bot never fetches an arbitrary user-supplied URL.
*/
export function findShareShortLink(raw: string): string | null {
const q = stripUrlBBCode(raw ?? "");
const m =
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
return m ? m[0] : null;
}
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
const redirectGet: RedirectGet = async (url) => {
const res = await axios.get(url, {
maxRedirects: 0,
timeout: 5000,
validateStatus: () => true,
responseType: "stream",
});
res.data?.destroy?.();
const location = res.headers.location;
return { status: res.status, location: typeof location === "string" ? location : undefined };
};
/** Follow a share short link one hop. Returns the target URL, or null. */
export async function resolveShareLink(
url: string,
get: RedirectGet = redirectGet,
): Promise<string | null> {
try {
const { status, location } = await get(url);
if (status < 300 || status >= 400 || !location) return null;
return new URL(location, url).toString();
} catch {
return null;
}
}
+33
View File
@@ -345,3 +345,36 @@ describe("guest principal migration", () => {
rmSync(dir, { recursive: true, force: true });
});
});
describe("user music cookies (#164)", () => {
let botDb: BotDatabase;
const addUser = (id: string) =>
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run(id, id, "x", 0, 0, "member");
beforeEach(() => {
botDb = createDatabase(":memory:");
addUser("u1");
addUser("u2");
});
afterEach(() => botDb.close());
it("stores, overwrites and deletes a cookie per user and platform", () => {
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=b");
expect(botDb.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=b");
expect(botDb.getUserMusicCookie("u2", "netease")).toBeNull();
expect(botDb.getUserMusicCookie("u1", "qq")).toBeNull();
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(true);
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(false);
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
});
it("drops a user's cookies when the user is deleted", () => {
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
botDb.db.prepare("DELETE FROM users WHERE id = ?").run("u1");
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
});
});
+39
View File
@@ -144,6 +144,10 @@ export interface BotDatabase {
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
// Per-user music account cookies (#164).
getUserMusicCookie(userId: string, platform: string): string | null;
setUserMusicCookie(userId: string, platform: string, cookie: string): void;
deleteUserMusicCookie(userId: string, platform: string): boolean;
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
@@ -340,6 +344,17 @@ function initTables(db: Database.Database): void {
fmPlatform TEXT NOT NULL DEFAULT '',
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
-- A web user's own music-platform login (#164), used for their personal
-- FM instead of the bot's shared account. Secret: never sent to clients.
CREATE TABLE IF NOT EXISTS user_music_cookies (
userId TEXT NOT NULL,
platform TEXT NOT NULL,
cookie TEXT NOT NULL,
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (userId, platform),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
`);
}
@@ -465,6 +480,17 @@ export function createDatabase(dbPath: string): BotDatabase {
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectUserMusicCookie = db.prepare(
`SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`,
);
const upsertUserMusicCookie = db.prepare(`
INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?)
ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now')
`);
const deleteUserMusicCookieStmt = db.prepare(
`DELETE FROM user_music_cookies WHERE userId = ? AND platform = ?`,
);
// A corrupt/hand-edited songs blob must never throw into a route or the
// restore path — degrade to an empty list instead.
const parseSongs = (raw: string): StoredSong[] => {
@@ -646,6 +672,19 @@ export function createDatabase(dbPath: string): BotDatabase {
return row !== undefined;
},
getUserMusicCookie(userId, platform) {
const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined;
return row?.cookie ?? null;
},
setUserMusicCookie(userId, platform, cookie) {
upsertUserMusicCookie.run(userId, platform, cookie);
},
deleteUserMusicCookie(userId, platform) {
return deleteUserMusicCookieStmt.run(userId, platform).changes > 0;
},
saveQueue(ownerId, name, songs) {
if (songs.length > MAX_QUEUE_SONGS) {
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
+125
View File
@@ -37,3 +37,128 @@ describe("BiliBiliProvider.search pagination", () => {
expect(searchParams(get).page).toBe("1");
});
});
describe("BiliBiliProvider multi-P support", () => {
it("parseBilibiliId extracts bvid and page correctly", async () => {
const { parseBilibiliId } = await import("./bilibili.js");
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
bvid: "BV1yxHQeYEuE",
page: 5,
});
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
});
function mockViewProvider(viewData: any, playUrlData?: any) {
const p = new BiliBiliProvider();
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
if (url === "/x/web-interface/view") {
return Promise.resolve({ data: { data: viewData } });
}
if (url === "/x/player/playurl") {
return Promise.resolve({ data: { data: playUrlData ?? {} } });
}
return Promise.resolve({ data: {} });
});
(p as any).buvidInitialized = true;
(p as any).api = { get };
return { p, get };
}
const multiPViewData = {
bvid: "BV1multiP",
title: "测试多P教程",
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
duration: 300, // 总时长 300 秒 (120 + 180)
owner: { name: "UP主测试" },
pages: [
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
],
};
const singlePViewData = {
bvid: "BV1singleP",
title: "测试单P视频",
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
duration: 200,
owner: { name: "UP主测试" },
pages: [
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
],
};
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
const { p } = mockViewProvider(singlePViewData);
const song = await p.getSongDetail("BV1singleP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1singleP");
expect(song!.name).toBe("测试单P视频");
expect(song!.duration).toBe(200);
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=1");
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP?p=2");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=2");
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
expect(song!.duration).toBe(180); // P2 独立时长
expect(song!.platform).toBe("bilibili");
});
it("getVideoParts returns all parts with duration and cid", async () => {
const { p } = mockViewProvider(multiPViewData);
const partsResult = await p.getVideoParts("BV1multiP");
expect(partsResult).not.toBeNull();
expect(partsResult!.bvid).toBe("BV1multiP");
expect(partsResult!.title).toBe("测试多P教程");
expect(partsResult!.parts).toHaveLength(2);
expect(partsResult!.parts[0]).toEqual({
part: 1,
cid: 10001,
title: "第一讲 入门",
duration: 120,
});
expect(partsResult!.parts[1]).toEqual({
part: 2,
cid: 10002,
title: "第二讲 进阶",
duration: 180,
});
});
it("getSongUrl requests playurl with correct cid for specific part", async () => {
const playUrlResponse = {
dash: {
audio: [
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
],
},
};
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
const result = await p.getSongUrl("BV1multiP?p=2");
expect(result).not.toBeNull();
expect(result!.url).toBe("http://audio.320k.test");
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
expect(playurlCall).toBeTruthy();
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
+106 -11
View File
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export interface BiliVideoPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliVideoPartsResult {
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliVideoPart[];
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
const str = (songId ?? "").trim();
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
if (!bvMatch) {
return { bvid: str, page: 1 };
}
const bvid = bvMatch[0];
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
const page = pageStr ? parseInt(pageStr, 10) : 1;
return { bvid, page: Math.max(1, page) };
}
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -192,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
const { bvid, page } = parseBilibiliId(songId);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
params: { bvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
// Cache cid for later audio URL fetching
if (data.pages?.[0]?.cid) {
this.cidCache.set(songId, data.pages[0].cid);
const pages = data.pages ?? [];
// 缓存所有分P的 cid 映射
for (const p of pages) {
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(bvid, pages[0].cid);
}
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
// 若为多P视频,返回对应分P的名称与独立时长
if (pages.length > 1 && targetPage) {
const partTitle = targetPage.part && targetPage.part !== data.title
? `${data.title} - P${targetPage.page} ${targetPage.part}`
: `${data.title} (P${targetPage.page})`;
return {
id: `${bvid}?p=${targetPage.page}`,
name: partTitle,
artist: data.owner?.name ?? "",
album: "",
duration: targetPage.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
}
return {
@@ -211,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
duration: targetPage?.duration ?? data.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
@@ -220,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
}
}
/** 获取视频所有分P列表 */
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
const { bvid: cleanBvid } = parseBilibiliId(bvid);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: cleanBvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
const pages = data.pages ?? [];
for (const p of pages) {
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(cleanBvid, pages[0].cid);
}
return {
bvid: cleanBvid,
title: data.title ?? "",
coverUrl: this.normalizeCover(data.pic ?? ""),
artist: data.owner?.name ?? "",
parts: pages.map((p: any) => ({
part: p.page,
cid: p.cid,
title: p.part ?? `P${p.page}`,
duration: p.duration ?? 0,
})),
};
} catch {
return null;
}
}
/** Get CID for a bvid, using cache when available */
private async getCid(bvid: string): Promise<number | null> {
const cached = this.cidCache.get(bvid);
private async getCid(bvid: string, page = 1): Promise<number | null> {
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
if (cached) return cached;
// Limit cache size to prevent unbounded growth
@@ -231,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
if (firstKey) this.cidCache.delete(firstKey);
}
const detail = await this.getSongDetail(bvid);
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
const detail = await this.getSongDetail(songId);
if (!detail) return null;
return this.cidCache.get(bvid) ?? null;
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
const { bvid, page } = parseBilibiliId(songId);
const cid = await this.getCid(bvid, page);
if (!cid) return null;
try {
const res = await this.api.get("/x/player/playurl", {
params: {
cid,
bvid: songId,
bvid,
fnval: 16, // DASH format
},
headers: this.cookieHeaders,
+45
View File
@@ -139,3 +139,48 @@ describe("NeteaseProvider.search pagination", () => {
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");
});
});
+33 -9
View File
@@ -111,8 +111,10 @@ export class NeteaseProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
@@ -243,25 +245,47 @@ export class NeteaseProvider implements MusicProvider {
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const { status, cookie } = await this.pollQrLogin(key);
if (cookie) this.cookie = cookie;
return status;
}
/**
* Poll a QR login and hand back the resulting cookie WITHOUT storing it on
* this provider — for a web user linking their own account (#164), which
* must never replace the bot's shared login.
*/
async pollQrLogin(
key: string
): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }> {
const res = await this.api.get("/login/qr/check", {
params: { key, timestamp: Date.now() },
});
const code = res.data?.code;
switch (code) {
switch (res.data?.code) {
case 801:
return "waiting";
return { status: "waiting" };
case 802:
return "scanned";
return { status: "scanned" };
case 803:
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
return res.data?.cookie
? { status: "confirmed", cookie: res.data.cookie }
: { status: "confirmed" };
default:
return "expired";
return { status: "expired" };
}
}
/**
* A provider for the same API server logged in as another account (#164):
* a web user's personal FM uses their own taste instead of the shared login.
*/
withCookie(cookie: string): NeteaseProvider {
const view = new NeteaseProvider(this.baseUrl);
view.setQuality(this.quality);
view.setCookie(cookie);
return view;
}
async sendSmsCode(phone: string): Promise<boolean> {
const res = await this.api.get("/captcha/sent", {
params: { phone },
+65
View File
@@ -438,3 +438,68 @@ describe("music router POST /local/upload — content types and size cap (#149)"
expect(uploadAudio).not.toHaveBeenCalled();
});
});
describe("music router GET /bilibili/parts", () => {
it("returns 400 when bvid is missing", async () => {
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts");
expect(res.status).toBe(400);
expect(res.body.error).toBe("bvid is required");
});
it("returns parts from bilibili provider when available", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue({
bvid: "BV1test",
title: "多P视频测试",
parts: [
{ part: 1, cid: 101, title: "P1", duration: 100 },
{ part: 2, cid: 102, title: "P2", duration: 200 },
],
}),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
expect(res.status).toBe(200);
expect(res.body.bvid).toBe("BV1test");
expect(res.body.parts).toHaveLength(2);
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
});
it("returns 404 when getVideoParts returns null", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue(null),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
expect(res.status).toBe(404);
});
});
+25
View File
@@ -382,6 +382,31 @@ export function createMusicRouter(
}
});
// B站分P列表查询
router.get("/bilibili/parts", async (req, res) => {
try {
const bvid = (req.query.bvid as string)?.trim();
if (!bvid) {
res.status(400).json({ error: "bvid is required" });
return;
}
const provider = bilibiliProvider as any;
if (typeof provider.getVideoParts === "function") {
const result = await provider.getVideoParts(bvid);
if (!result) {
res.status(404).json({ error: "Video not found" });
return;
}
res.json(result);
} else {
res.status(501).json({ error: "Not supported" });
}
} catch (err) {
logger.error({ err }, "Get bilibili parts failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createDatabase } from "../../data/database.js";
import { createPersonalMusicRouter } from "./personal-music.js";
import { createPlayerRouter } from "./player.js";
function mount() {
const db = createDatabase(":memory:");
db.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run("u1", "alice", "x", 0, 0, "member");
const personalView = {
getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })),
};
const provider: any = {
platform: "netease",
getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })),
pollQrLogin: vi.fn(async () => ({ status: "waiting" })),
withCookie: vi.fn(() => personalView),
setCookie: vi.fn(),
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: "u1", username: "alice", role: "member" };
next();
});
app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" })));
return { app, db, provider, personalView };
}
describe("personal music account router (#164)", () => {
it("reports not linked until the user logs in", async () => {
const { app } = mount();
const res = await request(app).get("/api/me/music/netease/status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ linked: false, loggedIn: false });
});
it("creates a QR code", async () => {
const { app } = mount();
const res = await request(app).post("/api/me/music/netease/qrcode");
expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" });
});
it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => {
const { app, db, provider } = mount();
provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" });
const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" });
expect(res.body).toEqual({ status: "confirmed" });
expect(JSON.stringify(res.body)).not.toContain("MUSIC_U");
expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice");
expect(provider.setCookie).not.toHaveBeenCalled();
});
it("requires a key to poll", async () => {
const { app } = mount();
expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400);
});
it("reports the linked account's nickname via a view on the user's cookie", async () => {
const { app, db, provider } = mount();
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
const res = await request(app).get("/api/me/music/netease/status");
expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" });
expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
});
it("unlinks", async () => {
const { app, db } = mount();
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
expect((await request(app).delete("/api/me/music/netease")).status).toBe(200);
expect(db.getUserMusicCookie("u1", "netease")).toBeNull();
});
});
describe("web FM uses the caller's linked NetEase account (#164)", () => {
async function startFm(opts: { linked: boolean; role?: string; platform?: string }) {
const db = createDatabase(":memory:");
db.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
.run("u1", "alice", "x", 0, 0, "member");
if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
const personal = { platform: "netease", personal: true };
const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) };
const qq: any = { platform: "qq" };
const bot = {
id: "b1",
getProviderFor: (p: string) => (p === "qq" ? qq : shared),
startFm: vi.fn(async (_provider: unknown) => "Personal FM started"),
};
const botManager: any = { getBot: () => bot };
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = {
id: "u1", username: "alice", role: opts.role ?? "member",
capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true },
};
next();
});
app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db));
const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" });
return { res, bot, shared, personal, qq };
}
it("starts FM on the user's own account when linked", async () => {
const { res, bot, shared, personal } = await startFm({ linked: true });
expect(res.status).toBe(200);
expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
expect(bot.startFm.mock.calls[0][0]).toBe(personal);
});
it("falls back to the shared account when the user has not linked one", async () => {
const { bot, shared } = await startFm({ linked: false });
expect(bot.startFm.mock.calls[0][0]).toBe(shared);
});
it("leaves other platforms alone", async () => {
const { bot, qq } = await startFm({ linked: true, platform: "qq" });
expect(bot.startFm.mock.calls[0][0]).toBe(qq);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider, QrCodeResult } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
/**
* A provider that can log a web user into their OWN account without touching
* the bot's shared login, and hand out a view bound to that account (#164).
*/
export interface PersonalLoginProvider {
getQrCode(): Promise<QrCodeResult>;
pollQrLogin(key: string): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }>;
withCookie(cookie: string): MusicProvider;
}
export function supportsPersonalLogin(
provider: MusicProvider | undefined,
): provider is MusicProvider & PersonalLoginProvider {
const p = provider as Partial<PersonalLoginProvider> | undefined;
return typeof p?.pollQrLogin === "function" && typeof p.withCookie === "function";
}
/**
* The caller's own NetEase account, used for their personal FM instead of the
* bot's shared login (#164). Every route acts on req.user only; the cookie is
* stored server-side and never sent back to the browser.
*/
export function createPersonalMusicRouter(
database: BotDatabase,
neteaseProvider: MusicProvider,
logger: Logger,
): Router {
const router = Router();
const platform = "netease";
router.use((_req, res, next) => {
if (!supportsPersonalLogin(neteaseProvider)) {
res.status(501).json({ error: "Personal login not supported" });
return;
}
next();
});
const provider = neteaseProvider as MusicProvider & PersonalLoginProvider;
router.get("/netease/status", async (req, res) => {
const cookie = database.getUserMusicCookie(req.user!.id, platform);
if (!cookie) {
res.json({ linked: false, loggedIn: false });
return;
}
try {
const status = await provider.withCookie(cookie).getAuthStatus();
res.json({ linked: true, ...status });
} catch (err) {
logger.warn({ err }, "Personal NetEase status check failed");
res.json({ linked: true, loggedIn: false });
}
});
router.post("/netease/qrcode", async (_req, res) => {
try {
res.json(await provider.getQrCode());
} catch (err) {
logger.error({ err }, "Personal NetEase QR generation failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/netease/qrcode/status", async (req, res) => {
const key = req.query.key;
if (typeof key !== "string" || !key) {
res.status(400).json({ error: "key is required" });
return;
}
try {
const { status, cookie } = await provider.pollQrLogin(key);
if (status === "confirmed" && cookie) {
database.setUserMusicCookie(req.user!.id, platform, cookie);
logger.info({ userId: req.user!.id, platform }, "Personal music account linked");
}
res.json({ status });
} catch (err) {
logger.error({ err }, "Personal NetEase QR status check failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.delete("/netease", (req, res) => {
database.deleteUserMusicCookie(req.user!.id, platform);
logger.info({ userId: req.user!.id, platform }, "Personal music account unlinked");
res.json({ ok: true });
});
return router;
}
+10 -1
View File
@@ -6,6 +6,7 @@ import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
import { requireBotAccess } from "../middleware/requirePermission.js";
import { authorize } from "../middleware/authorize.js";
import { supportsPersonalLogin } from "./personal-music.js";
export function createPlayerRouter(
botManager: BotManager,
@@ -124,11 +125,19 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
let provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform
: "netease"
);
// A signed-in user who linked their own NetEase account gets FM from
// THEIR taste, not the bot's shared login (#164). Songs still resolve
// through the shared provider when played.
const user = (req as any).user;
if (provider.platform === "netease" && user && user.role !== "guest" && database) {
const cookie = database.getUserMusicCookie(user.id, "netease");
if (cookie && supportsPersonalLogin(provider)) provider = provider.withCookie(cookie);
}
const message = await bot.startFm(provider, requesterName(req));
res.json({
ok:
+8
View File
@@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { createPersonalMusicRouter } from "./api/personal-music.js";
import { createSavedQueuesRouter } from "./api/saved-queues.js";
import { createSpotifyRouter } from "./api/spotify.js";
import { createApiKeysRouter } from "./api/api-keys.js";
@@ -206,6 +207,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
}
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
// The caller's own NetEase login for their personal FM (#164). Guests share
// one anonymous identity, so they cannot link an account.
app.use(
"/api/me/music",
requireNotGuest,
createPersonalMusicRouter(options.database, options.neteaseProvider, logger),
);
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
// the router itself 403s every route unless savedQueuesEnabled is on.
app.use(