mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could never be reached from chat (e.g. 'Die For You' always returned the most popular match, not The Weeknd's). Add three disambiguation paths via a shared resolver: - !search <name> — list the top matches (numbered, with id), remembered per bot - !play #N / !add #N — play/queue the Nth result of the last !search - !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text search keeps the historical top-hit behavior. WebUI search (20 results) already allowed picking same-name songs and is unchanged. Fixes #90 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
22ec7328fa
commit
287dd240b1
4 files changed
+215
-34
No files matched your search
@@ -296,11 +296,14 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `!play <歌名>` | 搜索并播放 |
|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
|
||||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||||
| `!add <歌名>` | 添加到播放队列 |
|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
|
||||||
|
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||||
|
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
||||||
|
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||||
| `!stop` | 停止播放并清空队列 |
|
| `!stop` | 停止播放并清空队列 |
|
||||||
@@ -513,6 +516,9 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部
|
|||||||
**Q:提示"无法获取播放链接"?**
|
**Q:提示"无法获取播放链接"?**
|
||||||
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
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:如何更换机器人所在频道?**
|
**Q:如何更换机器人所在频道?**
|
||||||
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
||||||
|
|
||||||
|
|||||||
+87
-32
@@ -6,12 +6,13 @@ import {
|
|||||||
} from "../ts-protocol/client.js";
|
} from "../ts-protocol/client.js";
|
||||||
import { AudioPlayer } from "../audio/player.js";
|
import { AudioPlayer } from "../audio/player.js";
|
||||||
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.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 {
|
import {
|
||||||
parseCommand,
|
parseCommand,
|
||||||
isAdminCommand,
|
isAdminCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
@@ -71,6 +72,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
private fmProvider: MusicProvider | null = null;
|
private fmProvider: MusicProvider | null = null;
|
||||||
|
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||||
|
private lastSearchResults: Song[] = [];
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -334,6 +337,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
throw new Error("Bot is not connected to TeamSpeak");
|
throw new Error("Bot is not connected to TeamSpeak");
|
||||||
}
|
}
|
||||||
switch (cmd.name) {
|
switch (cmd.name) {
|
||||||
|
case "search":
|
||||||
|
case "find":
|
||||||
|
return this.cmdSearch(cmd);
|
||||||
case "play":
|
case "play":
|
||||||
return this.cmdPlay(cmd);
|
return this.cmdPlay(cmd);
|
||||||
case "add":
|
case "add":
|
||||||
@@ -467,36 +473,84 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
/**
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
|
||||||
const provider = this.getProvider(cmd.flags);
|
* forms (issue #90):
|
||||||
const result = await provider.search(cmd.args, 1);
|
* 1) "#N" — the Nth result of the previous !search
|
||||||
if (result.songs.length === 0)
|
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
|
||||||
return `No results found for: ${cmd.args}`;
|
* 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 <name> 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<string> {
|
||||||
|
const p = this.config.commandPrefix;
|
||||||
|
if (!cmd.args) return `Usage: ${p}search <name> [-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:<id>):`,
|
||||||
|
...lines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
||||||
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
|
if (error) return error;
|
||||||
|
const song0 = song!;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song0 });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
// Reset failure counter on user-initiated play
|
// Reset failure counter on user-initiated play
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
if (!ok) return `Cannot play: ${song.name}`;
|
if (!ok) return `Cannot play: ${song0.name}`;
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${song0.name} - ${song0.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !add <song name>";
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
||||||
const provider = this.getProvider(cmd.flags);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
const result = await provider.search(cmd.args, 1);
|
if (error) return error;
|
||||||
if (result.songs.length === 0)
|
const s = song!;
|
||||||
return `No results found for: ${cmd.args}`;
|
|
||||||
|
|
||||||
const song = result.songs[0];
|
|
||||||
const wasIdle = this.player.getState() === "idle";
|
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.
|
// If nothing was playing, start this newly-added song immediately.
|
||||||
// Matches /api/player/:id/add-by-id behavior so both add paths feel
|
// 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();
|
this.player.resetFailures();
|
||||||
await this.resolveAndPlay(this.queue.current()!);
|
await this.resolveAndPlay(this.queue.current()!);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("stateChange");
|
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<string> {
|
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playnext <song name>";
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
||||||
const provider = this.getProvider(cmd.flags);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
const result = await provider.search(cmd.args, 1);
|
if (error) return error;
|
||||||
if (result.songs.length === 0)
|
const s = song!;
|
||||||
return `No results found for: ${cmd.args}`;
|
|
||||||
|
|
||||||
const song = result.songs[0];
|
|
||||||
const wasIdle = this.player.getState() === "idle";
|
const wasIdle = this.player.getState() === "idle";
|
||||||
// Capture the slot addNext WILL insert at, before mutating the queue.
|
// Capture the slot addNext WILL insert at, before mutating the queue.
|
||||||
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
|
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
|
||||||
@@ -531,19 +583,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.getCurrentIndex() < 0
|
this.queue.getCurrentIndex() < 0
|
||||||
? this.queue.size()
|
? this.queue.size()
|
||||||
: this.queue.getCurrentIndex() + 1;
|
: this.queue.getCurrentIndex() + 1;
|
||||||
this.queue.addNext({ ...song, platform: provider.platform });
|
this.queue.addNext({ ...s });
|
||||||
|
|
||||||
if (wasIdle) {
|
if (wasIdle) {
|
||||||
this.queue.playAt(insertedAt);
|
this.queue.playAt(insertedAt);
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
const ok = await this.resolveAndPlay(this.queue.current()!);
|
const ok = await this.resolveAndPlay(this.queue.current()!);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
if (!ok) return `Cannot play: ${song.name}`;
|
if (!ok) return `Cannot play: ${s.name}`;
|
||||||
return `Now playing: ${song.name} - ${song.artist}`;
|
return `Now playing: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Up next: ${song.name} - ${song.artist}`;
|
return `Up next: ${s.name} - ${s.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
@@ -868,11 +920,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
const p = this.config.commandPrefix;
|
const p = this.config.commandPrefix;
|
||||||
return [
|
return [
|
||||||
"TSMusicBot Commands:",
|
"TSMusicBot Commands:",
|
||||||
`${p}play <song> — Search and play`,
|
`${p}play <song> — Search and play (most popular match)`,
|
||||||
`${p}play -q <song> — Search from QQ Music`,
|
`${p}play -q <song> — Search from QQ Music`,
|
||||||
`${p}play -b <song> — Search from BiliBili`,
|
`${p}play -b <song> — Search from BiliBili`,
|
||||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
||||||
`${p}add <song> — Add to queue`,
|
`${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 id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
|
||||||
|
`${p}add <song> — Add to queue (also accepts #N / 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`,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:<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:<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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user