mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge pull request #145 from ZHANGTIANYAO1/fix/play-id-command-syntax
feat(bot): !play id <id> 与其他命令语法保持一致
This commit is contained in:
4 files changed
+100
-20
No files matched your search
@@ -348,8 +348,8 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||||
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
|
| `!play id <id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:<id>` 仍然可用 |
|
||||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id <id>` / 链接) |
|
||||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||||
| `!stop` | 停止播放并清空队列 |
|
| `!stop` | 停止播放并清空队列 |
|
||||||
@@ -564,7 +564,7 @@ teamspeak-music-bot/
|
|||||||
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
|
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
|
||||||
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
|
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
|
||||||
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
|
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
|
||||||
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id <id>` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
||||||
|
|
||||||
### enabledProviders:音源开关
|
### enabledProviders:音源开关
|
||||||
|
|
||||||
@@ -801,7 +801,7 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部
|
|||||||
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
||||||
|
|
||||||
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
|
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
|
||||||
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id <歌曲id>` 按 id 精确播放(`!search` 结果里每行末尾的 `[id:...]` 就是它);(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
||||||
|
|
||||||
**Q:如何更换机器人所在频道?**
|
**Q:如何更换机器人所在频道?**
|
||||||
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
||||||
|
|||||||
+10
-10
@@ -1113,9 +1113,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
|
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
|
||||||
* forms (issue #90):
|
* forms (issue #90):
|
||||||
* 1) "#N" — the Nth result of the previous !search
|
* 1) "#N" — the Nth result of the previous !search
|
||||||
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
|
* 2) id <id> / URL — an exact song (disambiguates same-name songs)
|
||||||
* 3) plain text — search, returning the single most-popular hit (legacy)
|
* 3) plain text — search, returning the single most-popular hit (legacy)
|
||||||
*/
|
*/
|
||||||
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
|
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
|
||||||
const args = (cmd.args ?? "").trim();
|
const args = (cmd.args ?? "").trim();
|
||||||
@@ -1131,7 +1131,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
return { song: this.lastSearchResults[sel - 1] };
|
return { song: this.lastSearchResults[sel - 1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) id:/URL — fetch that exact song.
|
// 2) id/URL — fetch that exact song.
|
||||||
const ref = parseSongRef(args);
|
const ref = parseSongRef(args);
|
||||||
if (ref) {
|
if (ref) {
|
||||||
if (ref.platform) this.assertProviderEnabled(ref.platform);
|
if (ref.platform) this.assertProviderEnabled(ref.platform);
|
||||||
@@ -1159,13 +1159,13 @@ export class BotInstance extends EventEmitter {
|
|||||||
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
|
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
|
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id <id>):`,
|
||||||
...lines,
|
...lines,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id <id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
const song0 = song!;
|
const song0 = song!;
|
||||||
@@ -1259,7 +1259,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id <id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
const s = song!;
|
const s = song!;
|
||||||
@@ -1283,7 +1283,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id <id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
const s = song!;
|
const s = song!;
|
||||||
@@ -1840,8 +1840,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
||||||
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
||||||
`${p}play #N — Play the Nth result of the last ${p}search`,
|
`${p}play #N — Play the Nth result of the last ${p}search`,
|
||||||
`${p}play id:<id> — Play an exact song by id / URL`,
|
`${p}play id <id> — Play an exact song by id / URL`,
|
||||||
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
`${p}add <song> — Add to queue (also accepts #N / id <id> / URL)`,
|
||||||
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
||||||
`${p}pause/resume — Pause/resume`,
|
`${p}pause/resume — Pause/resume`,
|
||||||
`${p}next/prev — Next/previous`,
|
`${p}next/prev — Next/previous`,
|
||||||
|
|||||||
@@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => {
|
|||||||
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
|
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #139: `!play id <id>` matches the "<command> <subcommand> <arg>"
|
||||||
|
// shape of every other command. The colon form stays supported — users have
|
||||||
|
// it in their chat scrollback and in older docs.
|
||||||
|
it("parses the space-separated id form", () => {
|
||||||
|
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
|
||||||
|
expect(parseSongRef("ID 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
|
||||||
|
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
|
||||||
|
expect(parseSongRef("id 185868.")).toEqual({ id: "185868", platform: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake a word merely starting with 'id' for an id reference", () => {
|
||||||
|
expect(parseSongRef("idol")).toBeNull();
|
||||||
|
expect(parseSongRef("identity 185868")).toBeNull();
|
||||||
|
expect(parseSongRef("id")).toBeNull();
|
||||||
|
expect(parseSongRef("id:")).toBeNull();
|
||||||
|
// Two remaining tokens are a search phrase, not an id.
|
||||||
|
expect(parseSongRef("id die for you")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without a colon, "id" is just a word — "ID 4" and "ID Bruno" are real track
|
||||||
|
// titles. The space form therefore only claims tokens that could actually be
|
||||||
|
// an id; everything else stays a search term.
|
||||||
|
it("only treats the space form as an id when the token looks like one", () => {
|
||||||
|
expect(parseSongRef("id Bruno")).toBeNull();
|
||||||
|
expect(parseSongRef("id Marshmello")).toBeNull();
|
||||||
|
expect(parseSongRef("id 4ever")).toBeNull();
|
||||||
|
// …while every real id shape is still accepted.
|
||||||
|
expect(parseSongRef("id 4")).toEqual({ id: "4", platform: null }); // numeric
|
||||||
|
expect(parseSongRef("id BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: null });
|
||||||
|
expect(parseSongRef("id 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); // QQ mid
|
||||||
|
expect(parseSongRef("id a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toEqual({
|
||||||
|
id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||||
|
platform: null,
|
||||||
|
}); // Jellyfin GUID / Kugou hash
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the colon form unrestricted, so a short or odd id still works", () => {
|
||||||
|
expect(parseSongRef("id:Bruno")).toEqual({ id: "Bruno", platform: null });
|
||||||
|
expect(parseSongRef("id: 4ever")).toEqual({ id: "4ever", platform: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let the space form swallow a pasted URL", () => {
|
||||||
|
// `id <url>` used to fall through to the URL branches; it still must.
|
||||||
|
expect(parseSongRef("id https://music.163.com/song?id=185868")).toEqual({
|
||||||
|
id: "185868",
|
||||||
|
platform: "netease",
|
||||||
|
});
|
||||||
|
expect(parseSongRef("id https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({
|
||||||
|
id: "004Z8Ihr0JIu5s",
|
||||||
|
platform: "qq",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
|
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
|
||||||
// These reuse ?id= but are not songs — they should fall through to search,
|
// These reuse ?id= but are not songs — they should fall through to search,
|
||||||
// not misresolve to getSongDetail(collectionId) and error "no song".
|
// not misresolve to getSongDetail(collectionId) and error "no song".
|
||||||
|
|||||||
+33
-6
@@ -18,9 +18,22 @@ export interface SongRef {
|
|||||||
platform: "netease" | "qq" | "bilibili" | null;
|
platform: "netease" | "qq" | "bilibili" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could this token plausibly BE an id on a supported platform?
|
||||||
|
* - NetEase / Kugou numeric ids → all digits
|
||||||
|
* - BiliBili → BV + 8-12 alphanumerics
|
||||||
|
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
|
||||||
|
* → 11+ chars from the id alphabet
|
||||||
|
* Deliberately conservative: anything rejected here just stays an ordinary
|
||||||
|
* search term, which is what it almost certainly was.
|
||||||
|
*/
|
||||||
|
function looksLikeSongId(token: string): boolean {
|
||||||
|
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect an explicit song reference in a query. Recognizes:
|
* Detect an explicit song reference in a query. Recognizes:
|
||||||
* - `id:<id>` → platform from flags/default
|
* - `id <id>` / `id:<id>` → platform from flags/default
|
||||||
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
|
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
|
||||||
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
|
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
|
||||||
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
|
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
|
||||||
@@ -30,11 +43,25 @@ export function parseSongRef(raw: string): SongRef | null {
|
|||||||
const q = (raw ?? "").trim();
|
const q = (raw ?? "").trim();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
|
|
||||||
// Explicit "id:<id>" — platform decided by the command's flags/default.
|
// Explicit id — platform decided by the command's flags/default. The
|
||||||
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
|
// separator is a colon or plain whitespace, so `id <id>` matches the
|
||||||
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
|
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
|
||||||
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
|
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
|
||||||
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
|
// from a chat paste ("id:12345." / "id:12345)") — no supported id
|
||||||
|
// (numeric / BVID / mid) ends in those.
|
||||||
|
//
|
||||||
|
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
|
||||||
|
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
|
||||||
|
// has to keep resolving as a URL. So the space form only claims tokens that
|
||||||
|
// could actually be an id; anything else falls through to the URL branches
|
||||||
|
// below and ultimately to a plain search.
|
||||||
|
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
|
||||||
|
if (idPrefix) {
|
||||||
|
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
|
||||||
|
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
|
||||||
|
return { id, platform: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
|
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
|
||||||
// a "BV..." token never collides with them).
|
// a "BV..." token never collides with them).
|
||||||
|
|||||||
Reference in new issue
Block a user