From 74ea8d26d4022d7206e949311f2b63d93c502aab Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sun, 9 Aug 2026 15:08:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(bot):=20!play=20id=20=20=E4=B8=8E?= =?UTF-8?q?=E5=85=B6=E4=BB=96=E5=91=BD=E4=BB=A4=E8=AF=AD=E6=B3=95=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 id 精确播放原本要写 `!play id:`,冒号在一堆 `!<命令> <子命令> <参数>` 的命令里显得很突兀。现在空格写法 `!play id ` 也可以,`!add` / `!playnext` 共用同一个解析器,一起生效。 `id:` 继续支持,不做废弃:用户的聊天记录、旧文档和 !search 输出里都是 这个写法。 冒号是个明确的标记,所以 `id:<任意内容>` 一律当 id。空格不是——「ID 4」和 「ID Bruno」都是真实存在的歌名,而且 `id <链接>` 原本会落到 URL 分支正常解析。 所以空格写法只认「长得像 id」的 token(纯数字 / BV 号 / 11 位以上的 id 字符), 其余照旧继续走 URL 识别,最后落到普通搜索,不会把搜索词误当成 id。 同步更新 !search 输出的提示、三条 Usage、!help 和 README。 Closes #139 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++--- src/bot/instance.ts | 20 +++++++-------- src/bot/song-ref.test.ts | 53 ++++++++++++++++++++++++++++++++++++++++ src/bot/song-ref.ts | 39 ++++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 65c4b0a..f5edca3 100644 --- a/README.md +++ b/README.md @@ -348,8 +348,8 @@ sudo systemctl start tsmusicbot | `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| | `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 | | `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) | -| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) | -| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:` / 链接) | +| `!play id ` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:` 仍然可用 | +| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id ` / 链接) | | `!pause` / `!resume` | 暂停 / 恢复播放 | | `!next` / `!prev` | 下一首 / 上一首 | | `!stop` | 停止播放并清空队列 | @@ -564,7 +564,7 @@ teamspeak-music-bot/ - **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目 - **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派) - **播放上报** — 播放开始 / 进度(约 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 ` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源 ### enabledProviders:音源开关 @@ -801,7 +801,7 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部 A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。 **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:如何更换机器人所在频道?** A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 86a7c12..8cc285e 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -1113,9 +1113,9 @@ export class BotInstance extends EventEmitter { /** * Resolve a !play/!add/!playnext argument into a single Song, supporting three * forms (issue #90): - * 1) "#N" — the Nth result of the previous !search - * 2) id: / URL — an exact song (disambiguates same-name songs) - * 3) plain text — search, returning the single most-popular hit (legacy) + * 1) "#N" — the Nth result of the previous !search + * 2) id / URL — an exact song (disambiguates same-name songs) + * 3) plain text — search, returning the single most-popular hit (legacy) */ private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> { const args = (cmd.args ?? "").trim(); @@ -1131,7 +1131,7 @@ export class BotInstance extends EventEmitter { return { song: this.lastSearchResults[sel - 1] }; } - // 2) id:/URL — fetch that exact song. + // 2) id/URL — fetch that exact song. const ref = parseSongRef(args); if (ref) { 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}]`, ); return [ - `搜索结果(用 ${p}play #序号 播放,或 ${p}play id:):`, + `搜索结果(用 ${p}play #序号 播放,或 ${p}play id ):`, ...lines, ].join("\n"); } private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise { - if (!cmd.args) return `Usage: ${this.config.commandPrefix}play | URL>`; + if (!cmd.args) return `Usage: ${this.config.commandPrefix}play | URL>`; const { song, error } = await this.resolvePlayQuery(cmd); if (error) return error; const song0 = song!; @@ -1259,7 +1259,7 @@ export class BotInstance extends EventEmitter { } private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise { - if (!cmd.args) return `Usage: ${this.config.commandPrefix}add | URL>`; + if (!cmd.args) return `Usage: ${this.config.commandPrefix}add | URL>`; const { song, error } = await this.resolvePlayQuery(cmd); if (error) return error; const s = song!; @@ -1283,7 +1283,7 @@ export class BotInstance extends EventEmitter { } private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise { - if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext | URL>`; + if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext | URL>`; const { song, error } = await this.resolvePlayQuery(cmd); if (error) return error; const s = song!; @@ -1840,8 +1840,8 @@ export class BotInstance extends EventEmitter { ...(flagHelp ? [` Source flags: ${flagHelp}`] : []), `${p}search — List top matches to pick a specific (same-name) song`, `${p}play #N — Play the Nth result of the last ${p}search`, - `${p}play id: — Play an exact song by id / URL`, - `${p}add — Add to queue (also accepts #N / id: / URL)`, + `${p}play id — Play an exact song by id / URL`, + `${p}add — Add to queue (also accepts #N / id / URL)`, `${p}playnext — Insert as next song (alias: ${p}pn)`, `${p}pause/resume — Pause/resume`, `${p}next/prev — Next/previous`, diff --git a/src/bot/song-ref.test.ts b/src/bot/song-ref.test.ts index eb8f126..3d41421 100644 --- a/src/bot/song-ref.test.ts +++ b/src/bot/song-ref.test.ts @@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => { expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null }); }); + // Issue #139: `!play id ` matches the " " + // 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 ` 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", () => { // These reuse ?id= but are not songs — they should fall through to search, // not misresolve to getSongDetail(collectionId) and error "no song". diff --git a/src/bot/song-ref.ts b/src/bot/song-ref.ts index ba5c126..a13092f 100644 --- a/src/bot/song-ref.ts +++ b/src/bot/song-ref.ts @@ -18,9 +18,22 @@ export interface SongRef { 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: - * - `id:` → platform from flags/default + * - `id ` / `id:` → platform from flags/default * - 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) * - 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(); if (!q) return null; - // Explicit "id:" — platform decided by the command's flags/default. - // Strip trailing punctuation that tags along from a chat paste ("id:12345." - // / "id:12345)") — no supported id (numeric / BVID / mid) ends in those. - const idPrefix = /^id:\s*(\S+)$/i.exec(q); - if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null }; + // Explicit id — platform decided by the command's flags/default. The + // separator is a colon or plain whitespace, so `id ` matches the + // `! ` shape of every other command (issue #139) while the + // older `id:` keeps working. Strip trailing punctuation that tags along + // 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:` is always an id. A + // space is not: "ID 4" and "ID Bruno" are real track titles, and `id ` + // 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 // a "BV..." token never collides with them).