mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
8ff51ea6e0
commit
6b82df4e50
5 files changed
+237
-5
No files matched your search
@@ -364,6 +364,7 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
|
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
|
||||||
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
||||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
||||||
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
||||||
|
|||||||
@@ -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<string> },
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
function makeCtx() {
|
||||||
|
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
|
||||||
|
const makeProvider = (platform: string) => ({
|
||||||
|
platform,
|
||||||
|
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
|
||||||
|
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
|
||||||
|
});
|
||||||
|
const providers: Record<string, any> = {
|
||||||
|
netease: makeProvider("netease"),
|
||||||
|
qq: makeProvider("qq"),
|
||||||
|
youtube: makeProvider("youtube"),
|
||||||
|
};
|
||||||
|
const queued: any[] = [];
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
queued,
|
||||||
|
getProvider: vi.fn(() => providers.netease),
|
||||||
|
getProviderFor: vi.fn((p: string) => providers[p]),
|
||||||
|
assertProviderEnabled: vi.fn(),
|
||||||
|
extractId: (BotInstance.prototype as any).extractId,
|
||||||
|
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
|
||||||
|
player: { stop: vi.fn() },
|
||||||
|
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
|
||||||
|
disableFmMode: vi.fn(),
|
||||||
|
withRequester: (s: any) => s,
|
||||||
|
resolveAndPlay: vi.fn(async () => true),
|
||||||
|
sweepLocalAudio: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cmd = (args: string, flags: string[] = []) =>
|
||||||
|
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
|
||||||
|
|
||||||
|
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
|
||||||
|
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
|
||||||
|
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.queued[0].platform).toBe("qq");
|
||||||
|
expect(reply).toMatch(/^Loaded 1 songs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
|
||||||
|
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
|
||||||
|
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the link's platform is enabled", async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
|
||||||
|
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the old behavior for a bare id", async () => {
|
||||||
|
const ctx = makeCtx();
|
||||||
|
await cmdPlaylist.call(ctx, cmd("2829883282"));
|
||||||
|
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-4
@@ -13,7 +13,13 @@ import {
|
|||||||
canRunCommand,
|
canRunCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} 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 { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.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<string> {
|
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
|
||||||
|
// 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
|
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
||||||
const id = this.extractId(cmd.args);
|
const id = this.extractId(cmd.args);
|
||||||
@@ -1522,7 +1541,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
let playlistId: string;
|
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
|
// Input is a direct ID or URL containing an ID — use existing logic
|
||||||
playlistId = id;
|
playlistId = id;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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)", () => {
|
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||||
it("returns null for a plain search term", () => {
|
it("returns null for a plain search term", () => {
|
||||||
@@ -120,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
|
|||||||
expect(parseSelectionIndex("")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
* 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
|
* 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);
|
const n = parseInt(m[1], 10);
|
||||||
return Number.isFinite(n) && n > 0 ? n : null;
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlaylistRef {
|
||||||
|
id: string;
|
||||||
|
platform: "netease" | "qq" | "youtube";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
|
||||||
|
function stripUrlBBCode(text: string): string {
|
||||||
|
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
|
||||||
|
* share text. The platform comes from the URL, so a QQ link works without
|
||||||
|
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
|
||||||
|
* the caller handles as before.
|
||||||
|
*/
|
||||||
|
export function parsePlaylistRef(raw: string): PlaylistRef | null {
|
||||||
|
const q = stripUrlBBCode(raw ?? "").trim();
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
if (/music\.163\.com/i.test(q)) {
|
||||||
|
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
|
||||||
|
if (m) return { id: m[1], platform: "netease" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/y\.qq\.com/i.test(q)) {
|
||||||
|
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
|
||||||
|
if (m) return { id: m[1], platform: "qq" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/youtube\.com|youtu\.be/i.test(q)) {
|
||||||
|
const m = /[?&]list=([\w-]+)/.exec(q);
|
||||||
|
if (m) return { id: m[1], platform: "youtube" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
|
||||||
|
* short link — what the phone apps copy. Only these hosts are recognized so
|
||||||
|
* the bot never fetches an arbitrary user-supplied URL.
|
||||||
|
*/
|
||||||
|
export function findShareShortLink(raw: string): string | null {
|
||||||
|
const q = stripUrlBBCode(raw ?? "");
|
||||||
|
const m =
|
||||||
|
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
|
||||||
|
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
|
||||||
|
return m ? m[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
|
||||||
|
|
||||||
|
const redirectGet: RedirectGet = async (url) => {
|
||||||
|
const res = await axios.get(url, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
timeout: 5000,
|
||||||
|
validateStatus: () => true,
|
||||||
|
responseType: "stream",
|
||||||
|
});
|
||||||
|
res.data?.destroy?.();
|
||||||
|
const location = res.headers.location;
|
||||||
|
return { status: res.status, location: typeof location === "string" ? location : undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Follow a share short link one hop. Returns the target URL, or null. */
|
||||||
|
export async function resolveShareLink(
|
||||||
|
url: string,
|
||||||
|
get: RedirectGet = redirectGet,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { status, location } = await get(url);
|
||||||
|
if (status < 300 || status >= 400 || !location) return null;
|
||||||
|
return new URL(location, url).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user