diff --git a/README.md b/README.md index abd5419..4b984a0 100644 --- a/README.md +++ b/README.md @@ -296,11 +296,14 @@ sudo systemctl start tsmusicbot | 命令 | 说明 | |------|------| -| `!play <歌名>` | 搜索并播放 | +| `!play <歌名>` | 搜索并播放(取最热门的匹配项) | | `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | | `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| -| `!add <歌名>` | 添加到播放队列 | +| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 | +| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) | +| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) | +| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:` / 链接) | | `!pause` / `!resume` | 暂停 / 恢复播放 | | `!next` / `!prev` | 下一首 / 上一首 | | `!stop` | 停止播放并清空队列 | @@ -513,6 +516,9 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部 **Q:提示"无法获取播放链接"?** 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 中则可直接在搜索结果列表里点选任意同名歌曲。 + **Q:如何更换机器人所在频道?** A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0472fc1..8931c98 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -6,12 +6,13 @@ import { } from "../ts-protocol/client.js"; import { AudioPlayer } from "../audio/player.js"; import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js"; -import type { MusicProvider } from "../music/provider.js"; +import type { MusicProvider, Song } from "../music/provider.js"; import { parseCommand, isAdminCommand, type ParsedCommand, } from "./commands.js"; +import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import type { Logger } from "../logger.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; @@ -71,6 +72,8 @@ export class BotInstance extends EventEmitter { private profileManager: BotProfileManager; private isFmMode = false; private fmProvider: MusicProvider | null = null; + /** Results of the most recent !search, for "#N" selection (issue #90). */ + private lastSearchResults: Song[] = []; constructor(options: BotInstanceOptions) { super(); @@ -334,6 +337,9 @@ export class BotInstance extends EventEmitter { throw new Error("Bot is not connected to TeamSpeak"); } switch (cmd.name) { + case "search": + case "find": + return this.cmdSearch(cmd); case "play": return this.cmdPlay(cmd); case "add": @@ -467,36 +473,84 @@ export class BotInstance extends EventEmitter { } } - private async cmdPlay(cmd: ParsedCommand): Promise { - if (!cmd.args) return "Usage: !play "; - const provider = this.getProvider(cmd.flags); - const result = await provider.search(cmd.args, 1); - if (result.songs.length === 0) - return `No results found for: ${cmd.args}`; + /** + * 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) + */ + private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> { + const args = (cmd.args ?? "").trim(); + const p = this.config.commandPrefix; - const song = result.songs[0]; + // 1) "#N" — pick from the previous !search. + const sel = parseSelectionIndex(args); + if (sel !== null) { + if (this.lastSearchResults.length === 0) + 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] }; + } + + // 2) id:/URL — fetch that exact song. + const ref = parseSongRef(args); + if (ref) { + const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags); + const song = await provider.getSongDetail(ref.id); + if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` }; + return { song: { ...song, platform: provider.platform } }; + } + + // 3) Plain search term — single most-popular hit (historical behavior). + 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 } }; + } + + private async cmdSearch(cmd: ParsedCommand): Promise { + const p = this.config.commandPrefix; + if (!cmd.args) return `Usage: ${p}search [-q|-b|-y]`; + const provider = this.getProvider(cmd.flags); + const result = await provider.search(cmd.args, 8); + if (result.songs.length === 0) return `No results found for: ${cmd.args}`; + this.lastSearchResults = result.songs.map((s) => ({ ...s, platform: provider.platform })); + const lines = this.lastSearchResults.map( + (s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`, + ); + return [ + `搜索结果(用 ${p}play #序号 播放,或 ${p}play id:):`, + ...lines, + ].join("\n"); + } + + private async cmdPlay(cmd: ParsedCommand): Promise { + if (!cmd.args) return `Usage: ${this.config.commandPrefix}play | URL>`; + const { song, error } = await this.resolvePlayQuery(cmd); + if (error) return error; + const song0 = song!; this.queue.clear(); this.disableFmMode(); - this.queue.add({ ...song, platform: provider.platform }); + this.queue.add({ ...song0 }); this.queue.play(); // Reset failure counter on user-initiated play this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); - if (!ok) return `Cannot play: ${song.name}`; - return `Now playing: ${song.name} - ${song.artist}`; + if (!ok) return `Cannot play: ${song0.name}`; + return `Now playing: ${song0.name} - ${song0.artist}`; } private async cmdAdd(cmd: ParsedCommand): Promise { - if (!cmd.args) return "Usage: !add "; - const provider = this.getProvider(cmd.flags); - const result = await provider.search(cmd.args, 1); - if (result.songs.length === 0) - return `No results found for: ${cmd.args}`; + if (!cmd.args) return `Usage: ${this.config.commandPrefix}add | URL>`; + const { song, error } = await this.resolvePlayQuery(cmd); + if (error) return error; + const s = song!; - const song = result.songs[0]; const wasIdle = this.player.getState() === "idle"; - this.queue.add({ ...song, platform: provider.platform }); + this.queue.add({ ...s }); // If nothing was playing, start this newly-added song immediately. // Matches /api/player/:id/add-by-id behavior so both add paths feel @@ -506,21 +560,19 @@ export class BotInstance extends EventEmitter { this.player.resetFailures(); await this.resolveAndPlay(this.queue.current()!); this.emit("stateChange"); - return `Now playing: ${song.name} - ${song.artist}`; + return `Now playing: ${s.name} - ${s.artist}`; } this.emit("stateChange"); - return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`; + return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`; } private async cmdPlayNext(cmd: ParsedCommand): Promise { - if (!cmd.args) return "Usage: !playnext "; - const provider = this.getProvider(cmd.flags); - const result = await provider.search(cmd.args, 1); - if (result.songs.length === 0) - return `No results found for: ${cmd.args}`; + if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext | URL>`; + const { song, error } = await this.resolvePlayQuery(cmd); + if (error) return error; + const s = song!; - const song = result.songs[0]; const wasIdle = this.player.getState() === "idle"; // Capture the slot addNext WILL insert at, before mutating the queue. // addNext pushes when currentIndex<0 (slot = size); otherwise splices @@ -531,19 +583,19 @@ export class BotInstance extends EventEmitter { this.queue.getCurrentIndex() < 0 ? this.queue.size() : this.queue.getCurrentIndex() + 1; - this.queue.addNext({ ...song, platform: provider.platform }); + this.queue.addNext({ ...s }); if (wasIdle) { this.queue.playAt(insertedAt); this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); this.emit("stateChange"); - if (!ok) return `Cannot play: ${song.name}`; - return `Now playing: ${song.name} - ${song.artist}`; + if (!ok) return `Cannot play: ${s.name}`; + return `Now playing: ${s.name} - ${s.artist}`; } this.emit("stateChange"); - return `Up next: ${song.name} - ${song.artist}`; + return `Up next: ${s.name} - ${s.artist}`; } private cmdPause(): string { @@ -868,11 +920,14 @@ export class BotInstance extends EventEmitter { const p = this.config.commandPrefix; return [ "TSMusicBot Commands:", - `${p}play — Search and play`, + `${p}play — Search and play (most popular match)`, `${p}play -q — Search from QQ Music`, `${p}play -b — Search from BiliBili`, `${p}play -y — Search from YouTube (yt-dlp)`, - `${p}add — Add to queue`, + `${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 (NetEase·QQ·BiliBili)`, + `${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 new file mode 100644 index 0000000..5f36422 --- /dev/null +++ b/src/bot/song-ref.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; + +describe("parseSongRef (#90 exact-song selection)", () => { + it("returns null for a plain search term", () => { + expect(parseSongRef("Die For You")).toBeNull(); + expect(parseSongRef("周杰伦 晴天")).toBeNull(); + expect(parseSongRef("")).toBeNull(); + // A bare number is NOT treated as an id (a song may be named "2002"). + expect(parseSongRef("2002")).toBeNull(); + }); + + it("parses an explicit id: prefix with no platform (defer to flags)", () => { + expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null }); + expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); + }); + + it("parses NetEase song URLs", () => { + expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" }); + expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" }); + expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" }); + }); + + it("parses QQ song URLs", () => { + expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" }); + expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" }); + }); + + it("parses BiliBili BV ids (bare or in a URL)", () => { + expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" }); + expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" }); + expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" }); + }); +}); + +describe("parseSelectionIndex (#90 pick from last search)", () => { + it("parses #N tokens (1-based)", () => { + expect(parseSelectionIndex("#1")).toBe(1); + expect(parseSelectionIndex("#2")).toBe(2); + expect(parseSelectionIndex("# 3")).toBe(3); + expect(parseSelectionIndex(" #10 ")).toBe(10); + }); + + it("rejects non-selections", () => { + expect(parseSelectionIndex("2")).toBeNull(); + expect(parseSelectionIndex("#0")).toBeNull(); + expect(parseSelectionIndex("#-1")).toBeNull(); + expect(parseSelectionIndex("Die For You")).toBeNull(); + expect(parseSelectionIndex("#2 extra")).toBeNull(); + expect(parseSelectionIndex("")).toBeNull(); + }); +}); diff --git a/src/bot/song-ref.ts b/src/bot/song-ref.ts new file mode 100644 index 0000000..470553d --- /dev/null +++ b/src/bot/song-ref.ts @@ -0,0 +1,68 @@ +/** + * 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 + * most-popular search hit (issue #90). + * + * Two mechanisms: + * - A song reference: an explicit id / platform URL → play that exact song. + * - A selection index: "#N" → the Nth result of the previous !search. + */ + +export interface SongRef { + id: string; + /** + * Platform inferred from a URL. `null` means the platform wasn't encoded in + * the reference (e.g. a bare `id:`), so the caller should fall back to the + * command's flags / default provider. + */ + platform: "netease" | "qq" | "bilibili" | null; +} + +/** + * Detect an explicit song reference in a query. Recognizes: + * - `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 + * Returns `null` for a plain search term (the common case). + */ +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. + const idPrefix = /^id:\s*(\S+)$/i.exec(q); + if (idPrefix) return { id: idPrefix[1], platform: null }; + + // BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so + // a "BV..." token never collides with them). + const bv = /BV[0-9A-Za-z]{8,12}/.exec(q); + if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) { + return { id: bv[0], platform: "bilibili" }; + } + + // NetEase song URL. + if (/music\.163\.com/i.test(q)) { + const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q); + if (m) return { id: m[1], platform: "netease" }; + } + + // QQ song URL. + if (/y\.qq\.com/i.test(q)) { + const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q); + if (m) return { id: m[1], platform: "qq" }; + } + + return null; +} + +/** + * Detect a "#N" selection token (1-based) referencing the previous !search. + * Returns the positive integer, or `null` when the query isn't a selection. + */ +export function parseSelectionIndex(raw: string): number | null { + const m = /^#\s*(\d+)$/.exec((raw ?? "").trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + return Number.isFinite(n) && n > 0 ? n : null; +}