mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
`!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>
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
import axios from "axios";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 <id>` / `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 — platform decided by the command's flags/default. The
|
|
// separator is a colon or plain whitespace, so `id <id>` matches the
|
|
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
|
|
// older `id:<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:<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
|
|
// 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. Only treat `id=N` as a SONG id when the URL is not a
|
|
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
|
|
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
|
|
// confusing "no song" error instead of falling back to a normal search.
|
|
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|