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 c0697b7..fff584f 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"; @@ -1518,8 +1524,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); @@ -1527,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 { 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; + } +}