From 3c0e8df763917417c6cb4b8fc5041ffb06fe66a3 Mon Sep 17 00:00:00 2001 From: xxmod <73355112+xxmod@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:10:54 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(bilibili):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=86B=E7=AB=99=E5=88=86P=E8=A7=86=E9=A2=91=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=97=B6=E5=8F=AA=E8=83=BD=E6=92=AD=E6=94=BE=E7=AC=AC?= =?UTF-8?q?=E4=B8=80P=EF=BC=8C=E4=B8=94=E6=97=B6=E9=95=BF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=BA=E8=A7=86=E9=A2=91=E6=80=BB=E6=97=B6=E9=95=BF?= =?UTF-8?q?=20=E5=9C=A8=E7=BD=91=E9=A1=B5=E7=AB=AF=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=A4=9AP=E8=A7=86=E9=A2=91=E6=97=B6=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E9=80=89=E6=8B=A9=E9=9C=80=E8=A6=81=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=9A=84P=E6=95=B0=EF=BC=8Cts=E9=87=8C!play=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=99=E5=8F=AA=E6=92=AD=E6=94=BE=E7=AC=AC=E4=B8=80?= =?UTF-8?q?P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot/instance.test.ts | 52 ++++ src/bot/instance.ts | 22 +- src/music/bilibili.test.ts | 125 ++++++++++ src/music/bilibili.ts | 117 ++++++++- src/web/api/music.test.ts | 65 +++++ src/web/api/music.ts | 25 ++ web/src/App.vue | 2 + web/src/components/BilibiliPartModal.vue | 293 +++++++++++++++++++++++ web/src/stores/player.ts | 110 ++++++++- 9 files changed, 795 insertions(+), 16 deletions(-) create mode 100644 web/src/components/BilibiliPartModal.vue diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 137c907..2474ce2 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1538,3 +1538,55 @@ 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); + }); +}); + diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 58e74ca..4fc0638 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -968,6 +968,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 +1142,12 @@ export class BotInstance extends EventEmitter { return { error: `No recent search. Use ${p}search 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 +1164,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 { diff --git a/src/music/bilibili.test.ts b/src/music/bilibili.test.ts index da3c4da..cb43cc5 100644 --- a/src/music/bilibili.test.ts +++ b/src/music/bilibili.test.ts @@ -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 + }); +}); diff --git a/src/music/bilibili.ts b/src/music/bilibili.ts index cde63c2..fa0e080 100644 --- a/src/music/bilibili.ts +++ b/src/music/bilibili.ts @@ -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 { + 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 { + 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 { - const cached = this.cidCache.get(bvid); + private async getCid(bvid: string, page = 1): Promise { + 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 { - 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, diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 42f10ef..b066b8b 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -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); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 3c5865e..f54876a 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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. diff --git a/web/src/App.vue b/web/src/App.vue index 51ae196..55e5675 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,6 +7,7 @@ +
@@ -102,6 +103,7 @@ import Player from './components/Player.vue'; import CoverArt from './components/CoverArt.vue'; import Toast from './components/Toast.vue'; import Queue from './components/Queue.vue'; +import BilibiliPartModal from './components/BilibiliPartModal.vue'; const playerStore = usePlayerStore(); const session = useSession(); diff --git a/web/src/components/BilibiliPartModal.vue b/web/src/components/BilibiliPartModal.vue new file mode 100644 index 0000000..10aca6b --- /dev/null +++ b/web/src/components/BilibiliPartModal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index dfc6ac4..696033a 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -17,6 +17,24 @@ export interface Song { export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify'; +export interface BiliPart { + part: number; + cid: number; + title: string; + duration: number; +} + +export interface BiliPartModalState { + open: boolean; + song: Song | null; + action: 'play' | 'playNext' | 'add'; + bvid: string; + title: string; + coverUrl: string; + artist: string; + parts: BiliPart[]; +} + export interface AlbumItem { id: string; name: string; @@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', { // Transient notification for surfacing failures (e.g., "song not playable") // to a global Toast. Bumped `id` triggers re-render of the same message. notification: null as { id: number; message: string; type: 'error' | 'info' } | null, + + // Bilibili 多P分P选择弹窗状态 + biliPartModal: { + open: false, + song: null, + action: 'play', + bvid: '', + title: '', + coverUrl: '', + artist: '', + parts: [] as BiliPart[], + } as BiliPartModalState, }), getters: { @@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', { this.notification = { id: Date.now(), message, type }; }, - async playSong(song: Song) { + /** + * 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续 + */ + async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise { + try { + const cleanBvid = song.id.split('?')[0].split(':')[0]; + const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } }); + const parts: BiliPart[] = res.data?.parts ?? []; + if (parts.length > 1) { + this.biliPartModal = { + open: true, + song, + action, + bvid: cleanBvid, + title: res.data.title || song.name, + coverUrl: res.data.coverUrl || song.coverUrl, + artist: res.data.artist || song.artist, + parts, + }; + return true; // 弹窗接管 + } + if (parts.length === 1) { + song.duration = parts[0].duration; + } + } catch { + // 网络请求异常则降级为正常播放 + } + return false; + }, + + selectBilibiliPart(part: BiliPart) { + if (!this.biliPartModal.open || !this.biliPartModal.song) return; + const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal; + this.biliPartModal.open = false; + + const partTitle = part.title && part.title !== title + ? `${title} - P${part.part} ${part.title}` + : `${title} (P${part.part})`; + + const partSong: Song = { + ...song, + id: `${bvid}?p=${part.part}`, + name: partTitle, + artist: artist || song.artist, + coverUrl: coverUrl || song.coverUrl, + duration: part.duration, + }; + + if (action === 'play') { + this.playSong(partSong, true); + } else if (action === 'playNext') { + this.playNextSong(partSong, true); + } else if (action === 'add') { + this.addSong(partSong, true); + } + }, + + closeBilibiliPartModal() { + this.biliPartModal.open = false; + this.biliPartModal.song = null; + }, + + async playSong(song: Song, skipPartCheck = false) { if (!this.activeBotId) return; + if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) { + const handled = await this.checkBilibiliMultiPart(song, 'play'); + if (handled) return; + } // Guests use the non-destructive "play now" (insert-next + skip) so they // can't wipe everyone else's queue; members/admins keep the normal behavior. const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song'; @@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', { this._syncAfterAction(); }, - async playNextSong(song: Song) { + async playNextSong(song: Song, skipPartCheck = false) { if (!this.activeBotId) return; + if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) { + const handled = await this.checkBilibiliMultiPart(song, 'playNext'); + if (handled) return; + } const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song }); if (res.data?.message) { this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); @@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', { await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform }); }, - async addSong(song: Song) { + async addSong(song: Song, skipPartCheck = false) { if (!this.activeBotId) return; + if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) { + const handled = await this.checkBilibiliMultiPart(song, 'add'); + if (handled) return; + } await axios.post(`/api/player/${this.activeBotId}/add-song`, { song }); }, From faf6ac09ec75844edfcf6f7f77490027bbb18401 Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:36:57 +0800 Subject: [PATCH 2/6] fix(profile): move the now-playing channel description with the bot (#159) When the bot was moved to another channel, the channel it left kept the now-playing description forever: updateChannelDescription always targeted getChannelId(), which by then already reported the new channel. Remember which channel we last wrote to. On a self clientMoved event, clear that channel and, if a song is playing, write the description to the new one. Stop now clears the channel we actually wrote to, so a missed move event can't leave a stale description behind either. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/bot/instance.ts | 7 ++++- src/bot/profile.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++ src/bot/profile.ts | 52 ++++++++++++++++++++++++++++++---- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 4fc0638..c0697b7 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -456,11 +456,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); } diff --git a/src/bot/profile.test.ts b/src/bot/profile.test.ts index 59ebf93..2575e74 100644 --- a/src/bot/profile.test.ts +++ b/src/bot/profile.test.ts @@ -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 & { 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); + }); +}); diff --git a/src/bot/profile.ts b/src/bot/profile.ts index 3c04ef7..3ce0d20 100644 --- a/src/bot/profile.ts +++ b/src/bot/profile.ts @@ -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 { + 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 { + private async updateChannelDescription( + song: QueuedSong | null, + targetChannelId?: bigint, + ): Promise { 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); } From 6b82df4e50cbe1558291748999f80a53760cd3bf Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:41:05 +0800 Subject: [PATCH 3/6] feat(playlist): load a playlist straight from its link (#160) `!playlist` already pulled a numeric id out of a URL, but the platform still came from flags, so a QQ link without -q was looked up on NetEase, and a YouTube ?list= link fell through to a name search on the URL. - Detect NetEase / QQ Music / YouTube playlist links (also inside an app's share text and the [URL] BBCode TeamSpeak adds) and take the platform from the link. - Follow NetEase (163cn.tv) and QQ (c6.y.qq.com/base/fcgi-bin/u) share short links one hop. Only those hosts are fetched. - Document it in the README command table. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + src/bot/instance.test.ts | 67 +++++++++++++++++++++++++++++++++ src/bot/instance.ts | 29 ++++++++++++-- src/bot/song-ref.test.ts | 64 ++++++++++++++++++++++++++++++- src/bot/song-ref.ts | 81 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e58cd4b..187c45f 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,7 @@ sudo systemctl start tsmusicbot | `!mode ` | 切换播放模式 | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | +| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) | | `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) | | `!fm` | 私人 FM(默认网易云,自动续播) | diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 2474ce2..69abf14 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1590,3 +1590,70 @@ describe("BotInstance Bilibili multi-P resolution", () => { }); }); + +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 }, + ) => Promise; + + 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 = { + 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"); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 4fc0638..f3ebfe2 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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"; @@ -1513,8 +1519,21 @@ export class BotInstance extends EventEmitter { } private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise { - if (!cmd.args) return "Usage: !playlist "; - const provider = this.getProvider(cmd.flags); + if (!cmd.args) return "Usage: !playlist "; + + // 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); @@ -1522,7 +1541,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 { diff --git a/src/bot/song-ref.test.ts b/src/bot/song-ref.test.ts index 3d41421..d6e23f9 100644 --- a/src/bot/song-ref.test.ts +++ b/src/bot/song-ref.test.ts @@ -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(); + }); +}); diff --git a/src/bot/song-ref.ts b/src/bot/song-ref.ts index a13092f..6578067 100644 --- a/src/bot/song-ref.ts +++ b/src/bot/song-ref.ts @@ -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 { + try { + const { status, location } = await get(url); + if (status < 300 || status >= 400 || !location) return null; + return new URL(location, url).toString(); + } catch { + return null; + } +} From 88e6b5a691d01fe1c61b768d88787e38603cdf43 Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:42:59 +0800 Subject: [PATCH 4/6] fix(install): bring install.sh up to Node 22 and document both Linux scripts (#165) install.sh still installed Node 20 (dropped in #152), ran the Debian-only NodeSource script on yum systems, and hard-coded /usr/bin/node. The README only mentioned install.sh, not setup.sh. install.sh now: - installs Node 22 LTS from the right NodeSource repo per distro and checks the same 22.12+/24+ floor as setup.sh - delegates npm install, mirror detection, native-binary checks and the build to setup.sh, so the two scripts share one install path - stops the service and replaces dist/node_modules on re-install (data/ is kept), copies bin/ (yt-dlp), and uses the real node path in the unit README explains the difference between the two scripts and when to use which. setup.sh's "Node.js not found" message no longer says 20+. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 26 ++++++++++++- scripts/install.sh | 92 +++++++++++++++++++++++++++++++++------------- scripts/setup.sh | 2 +- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e58cd4b..f63aa5e 100644 --- a/README.md +++ b/README.md @@ -136,14 +136,35 @@ ports: -### 方式四:Linux 一键安装 +### 方式四:Linux 安装脚本 + +Linux 下有两个脚本,按需二选一: + +| | `scripts/install.sh`(一键安装 + 系统服务) | `scripts/setup.sh`(只安装构建) | +|---|---|---| +| 适合 | 想开箱即用、开机自启的服务器 | 想自己决定怎么常驻(screen / tmux / pm2 / 自写服务)的用户,或 macOS | +| Node.js | 没有或版本过低时**自动安装 Node 22 LTS**(apt / yum / pacman) | **不会安装**,需先自行装好 Node 22.12+ | +| 系统依赖 | 自动安装构建工具(和 FFmpeg,作为内置 FFmpeg 的后备) | 不安装,只提示 | +| 安装位置 | 构建后复制到 `/opt/tsmusicbot`(重装时保留 `data/`) | 就在当前项目目录 | +| 系统服务 | 自动配置 systemd 服务 `tsmusicbot` 并开机自启 | **不配置服务**,完成后自己 `npm start` | +| 需要 root | 是(`sudo`) | 否 | + +两者共用同一套安装逻辑:`install.sh` 会调用 `setup.sh` 完成依赖安装、国内网络镜像切换、原生模块校验和构建,然后再复制文件、配置服务。 + +**一键安装 + systemd 服务:** ```bash chmod +x scripts/install.sh sudo ./scripts/install.sh +# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f ``` -自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。 +**只安装构建(不装 Node、不配服务):** + +```bash +bash scripts/setup.sh +npm start +``` ## 更新升级 @@ -495,6 +516,7 @@ teamspeak-music-bot/ ├── scripts/ # 部署脚本 │ ├── setup.bat # Windows 首次安装 │ ├── start.bat # Windows 启动脚本 +│ ├── setup.sh # Linux/macOS 首次安装(只安装构建) │ ├── install.sh # Linux 一键安装 + systemd 服务 │ └── docker/ # Docker 部署文件 │ ├── Dockerfile diff --git a/scripts/install.sh b/scripts/install.sh index 4715d15..9a944cb 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash set -euo pipefail +# +# TSMusicBot Installer (Linux, systemd) +# - Installs system packages and Node.js 22 LTS +# - Runs scripts/setup.sh to install dependencies, verify native binaries and build +# - Copies the build to /opt/tsmusicbot and registers a systemd service (auto-start on boot) +# +# Only want to build and run it yourself (no Node install, no service)? +# Use scripts/setup.sh instead — see README「Linux 安装脚本」. +# + echo "╔══════════════════════════════════════╗" echo "║ TSMusicBot Installer ║" echo "╚══════════════════════════════════════╝" @@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DIR="/opt/tsmusicbot" SERVICE_NAME="tsmusicbot" +# Node LTS line to install when a supported Node is missing. Keep in sync with +# package.json "engines" and the floor check in setup.sh (#152). +NODE_LTS_MAJOR=22 # Verify we're in a valid project directory if [ ! -f "$PROJECT_DIR/package.json" ]; then @@ -28,42 +41,66 @@ else exit 1 fi -echo "[1/6] Installing system dependencies..." +# Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest). +node_supported() { + command -v node &> /dev/null && + node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)' +} + +echo "[1/5] Installing system dependencies..." case $OS in ubuntu|debian) sudo apt-get update -qq - sudo apt-get install -y -qq curl build-essential python3 + sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg ;; - centos|rhel|fedora) + centos|rhel|fedora|rocky|almalinux) sudo yum install -y curl gcc gcc-c++ make python3 ;; arch|manjaro) - sudo pacman -S --noconfirm curl base-devel python + sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg ;; *) - echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually." + echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually." ;; esac -echo "[2/6] Installing Node.js 20 LTS..." -if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null -fi -echo "Node.js $(node -v) installed" - -echo "[3/6] Installing dependencies..." -cd "$PROJECT_DIR" -npm install -if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then - (cd "$PROJECT_DIR/web" && npm install) +echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..." +if node_supported; then + echo "Node.js $(node -v) already installed" +else + case $OS in + ubuntu|debian) + curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash - + sudo apt-get install -y -qq nodejs + ;; + centos|rhel|fedora|rocky|almalinux) + curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash - + sudo yum install -y nodejs + ;; + arch|manjaro) + sudo pacman -S --noconfirm --needed nodejs npm + ;; + esac + if ! node_supported; then + echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)." + echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script." + exit 1 + fi + echo "Node.js $(node -v) installed" fi -echo "[4/6] Building project..." -npm run build +echo "[3/5] Installing dependencies and building (scripts/setup.sh)..." +bash "$SCRIPT_DIR/setup.sh" -echo "[5/6] Copying to $INSTALL_DIR..." +echo "[4/5] Copying to $INSTALL_DIR..." +# Stop a running copy before replacing its files (re-install / upgrade). +if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + sudo systemctl stop "$SERVICE_NAME" +fi sudo mkdir -p "$INSTALL_DIR" +# Replace build output wholesale so files removed upstream don't linger. +# data/ (config, database, cookies) is never touched. +sudo rm -rf "$INSTALL_DIR/dist" "$INSTALL_DIR/node_modules" "$INSTALL_DIR/web/dist" sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/" sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/" sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/" @@ -72,13 +109,18 @@ if [ -d "$PROJECT_DIR/web/dist" ]; then sudo mkdir -p "$INSTALL_DIR/web" sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/" fi +# yt-dlp is looked up in bin/ next to dist/ before falling back to PATH +if [ -d "$PROJECT_DIR/bin" ]; then + sudo cp -r "$PROJECT_DIR/bin" "$INSTALL_DIR/" +fi # Copy scripts for future use sudo mkdir -p "$INSTALL_DIR/scripts" sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true # Create data directory sudo mkdir -p "$INSTALL_DIR/data" -echo "[6/6] Creating systemd service..." +echo "[5/5] Creating systemd service..." +NODE_BIN="$(command -v node)" sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null </dev/null; then - echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org" + echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org" echo " or https://nodejs.cn/ (China mirror)." exit 1 fi From ac4a12d8bd765ce432e58de6de173ef01f93ee20 Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:02:31 +0800 Subject: [PATCH 5/6] feat(fm): let each web user link their own NetEase account for personal FM (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 2 +- src/data/database.test.ts | 33 +++ src/data/database.ts | 39 +++ src/music/netease.test.ts | 45 +++ src/music/netease.ts | 42 ++- src/web/api/personal-music.test.ts | 125 +++++++++ src/web/api/personal-music.ts | 95 +++++++ src/web/api/player.ts | 11 +- src/web/server.ts | 8 + web/src/components/PersonalNeteaseAccount.vue | 261 ++++++++++++++++++ web/src/views/Settings.vue | 2 + 11 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 src/web/api/personal-music.test.ts create mode 100644 src/web/api/personal-music.ts create mode 100644 web/src/components/PersonalNeteaseAccount.vue diff --git a/README.md b/README.md index e58cd4b..4686cda 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环 - **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步 -- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单 +- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号) - **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) diff --git a/src/data/database.test.ts b/src/data/database.test.ts index b03ff1e..64da9dc 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -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(); + }); +}); diff --git a/src/data/database.ts b/src/data/database.ts index 595913f..ddfe048 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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[]; @@ -328,6 +332,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 + ); `); } @@ -453,6 +468,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[] => { @@ -634,6 +660,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}`); diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts index aff9f89..800689f 100644 --- a/src/music/netease.test.ts +++ b/src/music/netease.test.ts @@ -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"); + }); +}); diff --git a/src/music/netease.ts b/src/music/netease.ts index 2a9acc8..05bcd14 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -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 { const res = await this.api.get("/captcha/sent", { params: { phone }, diff --git a/src/web/api/personal-music.test.ts b/src/web/api/personal-music.test.ts new file mode 100644 index 0000000..8faf1ad --- /dev/null +++ b/src/web/api/personal-music.test.ts @@ -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); + }); +}); diff --git a/src/web/api/personal-music.ts b/src/web/api/personal-music.ts new file mode 100644 index 0000000..dfc3007 --- /dev/null +++ b/src/web/api/personal-music.ts @@ -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; + 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 | 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; +} diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 8cdcac7..ab67326 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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: diff --git a/src/web/server.ts b/src/web/server.ts index a94a4b5..045d7c7 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; @@ -203,6 +204,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( diff --git a/web/src/components/PersonalNeteaseAccount.vue b/web/src/components/PersonalNeteaseAccount.vue new file mode 100644 index 0000000..9fb3eec --- /dev/null +++ b/web/src/components/PersonalNeteaseAccount.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 229fb9e..acf1a51 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -48,6 +48,7 @@

{{ ownPwError }}

{{ ownPwSuccess }}

+ @@ -1161,6 +1162,7 @@ import { Icon } from '@iconify/vue'; import axios from 'axios'; import AvatarUpload from '../components/AvatarUpload.vue'; import CustomAvatarRow from '../components/CustomAvatarRow.vue'; +import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue'; import QRCode from 'qrcode'; import { usePlayerStore } from '../stores/player.js'; import { useSession } from '../composables/useSession.js'; From 87fca6d8b7b770e1e01f8891059c99d53705cc08 Mon Sep 17 00:00:00 2001 From: TIANYAO ZHANG <88520881+ZHANGTIANYAO1@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:46:18 +0800 Subject: [PATCH 6/6] docs: add v1.14.0 changelog entry Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d2d1d38..99db724 100644 --- a/README.md +++ b/README.md @@ -907,7 +907,36 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部 > 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 -### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机 +### 最新版本 — v1.14.0:歌单链接直接播放 / 每人绑定自己的网易云私人FM / B站分P + +处理了 5 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;数据库会自动新增一张表(存放用户自己绑定的网易云账号),原有数据不受影响。 + +**`!playlist` 直接粘贴歌单链接([#160](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/160),[PR #169](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/169),感谢 [@JiaxiangACE](https://github.com/JiaxiangACE))** + +- `!playlist <歌单链接>` 支持网易云 / QQ 音乐 / YouTube 歌单链接,**平台由链接自动识别**:以前 QQ 链接不加 `-q` 会被拿去网易云查,YouTube 的 `?list=` 链接会被当成歌单名去搜索,现在都能直接用。 +- App 里「分享」复制出来的整段文案、以及短链(`163cn.tv`、`c6.y.qq.com`)也能直接粘贴。短链只会访问这两个域名,不会去请求任意用户给的地址。 +- 歌单名和纯数字 ID 的用法不变。 + +**每个网页端用户绑定自己的网易云账号听私人FM([#164](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/164),[PR #171](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/171),感谢 [@xxmod](https://github.com/xxmod))** + +- 多人共用一个机器人时,私人FM以前永远按机器人登录的那一个账号推荐。现在每个成员可以在 **设置 → 账户** 扫码绑定自己的网易云账号,之后他在网页端开启的网易云私人FM按他自己的口味推荐;未绑定的人照旧使用共享账号。 +- 绑定的登录只保存在服务器上,从不回传给浏览器,也**不会**顶掉机器人的共享登录;删除用户时一并清除。游客不能绑定。 +- TS 聊天里的 `!fm` 仍使用共享账号(聊天里的 TS 用户和网页账号没有对应关系)。 + +**机器人被移动后,原频道描述不再残留([#159](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/159),[PR #168](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/168),感谢 [@Almighty-ap](https://github.com/Almighty-ap))** + +- 开启「更新频道描述」时,把机器人拖到别的频道后,原频道会一直停留在当时的歌曲信息。现在机器人被移动时会清空原频道描述,并把正在播放的信息写到新频道。 + +**Linux 安装脚本([#165](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/165),[PR #172](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/172),感谢 [@XuVIIJay](https://github.com/XuVIIJay))** + +- `scripts/install.sh` 以前仍在安装已不再支持的 Node 20,现在按发行版(apt / yum / pacman)安装 Node 22 LTS,并复用 `setup.sh` 完成依赖安装、国内镜像切换、原生模块校验和构建。重复运行(升级)时会先停服务、替换构建产物,**保留 `data/`**。 +- README 的「Linux 安装脚本」一节说明了 `install.sh`(一键安装 + systemd 开机自启)和 `setup.sh`(只安装构建、不装 Node、不配服务)的区别和适用场景。 + +**B站分P视频([PR #166](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/166),感谢 [@xxmod](https://github.com/xxmod))** + +- 分P视频以前只能播放第一P,且时长显示为整个视频的总时长。现在网页端播放多P视频时会弹出选择框选P;TS 里 `!play` 播放第一P。 + +### v1.13.0:本地视频上传播放 / 头像上传时机 处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。