mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: implement server password login, YouTube source, and improved bot selector UI
- **TS server password** (#7/#9): add serverPassword field across database, manager, bot API, and Settings UI — allows joining password-protected servers - **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary; adds -y flag in chat commands, /api/music supports platform=youtube, YouTube badge in SongCard, yt-dlp-wrap npm dependency - **Bot selector UI** (#14): selector always visible (not just when >1 bot), bigger button with border and play-state indicator; per-bot URL routing at /bot/:id with BotRedirect view; copy-link button in dropdown Closes #1, #7, #9, #10, #14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b20ddf2603
commit
d5d6abeb1e
16 files changed
+391
-40
No files matched your search
+1
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
+7
-1
@@ -23,6 +23,7 @@ export interface BotInstanceOptions {
|
||||
neteaseProvider: MusicProvider;
|
||||
qqProvider: MusicProvider;
|
||||
bilibiliProvider: MusicProvider;
|
||||
youtubeProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -51,6 +52,7 @@ export class BotInstance extends EventEmitter {
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -65,6 +67,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.neteaseProvider = options.neteaseProvider;
|
||||
this.qqProvider = options.qqProvider;
|
||||
this.bilibiliProvider = options.bilibiliProvider;
|
||||
this.youtubeProvider = options.youtubeProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -217,14 +220,16 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "youtube") return this.youtubeProvider;
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
private getProvider(flags: Set<string>): MusicProvider {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
if (flags.has("y")) return this.youtubeProvider;
|
||||
return this.neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -477,6 +482,7 @@ export class BotInstance extends EventEmitter {
|
||||
`${p}play <song> — Search and play`,
|
||||
`${p}play -q <song> — Search from QQ Music`,
|
||||
`${p}play -b <song> — Search from BiliBili`,
|
||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
||||
`${p}add <song> — Add to queue`,
|
||||
`${p}pause/resume — Pause/resume`,
|
||||
`${p}next/prev — Next/previous`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type BotInstanceOptions,
|
||||
} from "./instance.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import { YouTubeProvider } from "../music/youtube.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
@@ -23,6 +24,8 @@ export interface CreateBotParams {
|
||||
serverProtocol?: ServerProtocol;
|
||||
/** API key for TS6 HTTP Query (port 10080/10443). */
|
||||
ts6ApiKey?: string;
|
||||
/** Password required to join the TS server. */
|
||||
serverPassword?: string;
|
||||
}
|
||||
|
||||
export class BotManager {
|
||||
@@ -30,6 +33,7 @@ export class BotManager {
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -45,6 +49,7 @@ export class BotManager {
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
this.qqProvider = qqProvider;
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
this.youtubeProvider = new YouTubeProvider();
|
||||
this.database = database;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
@@ -63,12 +68,14 @@ export class BotManager {
|
||||
nickname: params.nickname,
|
||||
defaultChannel: params.defaultChannel,
|
||||
channelPassword: params.channelPassword,
|
||||
serverPassword: params.serverPassword,
|
||||
serverProtocol: params.serverProtocol,
|
||||
ts6ApiKey: params.ts6ApiKey,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -87,6 +94,7 @@ export class BotManager {
|
||||
autoStart: params.autoStart ?? false,
|
||||
serverProtocol: params.serverProtocol ?? "",
|
||||
ts6ApiKey: params.ts6ApiKey ?? "",
|
||||
serverPassword: params.serverPassword ?? "",
|
||||
});
|
||||
|
||||
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
|
||||
@@ -118,6 +126,7 @@ export class BotManager {
|
||||
channelPassword: params.channelPassword ?? existing.channelPassword,
|
||||
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
|
||||
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
|
||||
serverPassword: params.serverPassword ?? existing.serverPassword,
|
||||
});
|
||||
// Update in-memory name immediately (other fields need reconnect)
|
||||
const bot = this.bots.get(id);
|
||||
@@ -157,12 +166,14 @@ export class BotManager {
|
||||
nickname: saved.nickname,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
serverPassword: saved.serverPassword || undefined,
|
||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
||||
ts6ApiKey: saved.ts6ApiKey || undefined,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -204,12 +215,14 @@ export class BotManager {
|
||||
identity: saved.identity || undefined,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
serverPassword: saved.serverPassword || undefined,
|
||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
||||
ts6ApiKey: saved.ts6ApiKey || undefined,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("database", () => {
|
||||
autoStart: true,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
|
||||
botDb.saveBotInstance(instance);
|
||||
@@ -90,6 +91,7 @@ describe("database", () => {
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
});
|
||||
|
||||
expect(botDb.deleteBotInstance("bot1")).toBe(true);
|
||||
|
||||
+11
-3
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface BotInstance {
|
||||
serverProtocol: string;
|
||||
/** API key for TS6 HTTP Query */
|
||||
ts6ApiKey: string;
|
||||
/** Password to join the TS server (server password) */
|
||||
serverPassword: string;
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,9 @@ function migrateSchema(db: Database.Database): void {
|
||||
if (!names.includes("ts6ApiKey")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!names.includes("serverPassword")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
function initTables(db: Database.Database): void {
|
||||
@@ -80,6 +85,7 @@ function initTables(db: Database.Database): void {
|
||||
autoStart INTEGER NOT NULL DEFAULT 0,
|
||||
serverProtocol TEXT NOT NULL DEFAULT '',
|
||||
ts6ApiKey TEXT NOT NULL DEFAULT '',
|
||||
serverPassword TEXT NOT NULL DEFAULT '',
|
||||
identity TEXT
|
||||
);
|
||||
`);
|
||||
@@ -101,8 +107,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
`);
|
||||
|
||||
const upsertInstance = db.prepare(`
|
||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, identity)
|
||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @identity)
|
||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
|
||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
serverAddress = excluded.serverAddress,
|
||||
@@ -113,6 +119,7 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
autoStart = excluded.autoStart,
|
||||
serverProtocol = excluded.serverProtocol,
|
||||
ts6ApiKey = excluded.ts6ApiKey,
|
||||
serverPassword = excluded.serverPassword,
|
||||
identity = excluded.identity
|
||||
`);
|
||||
|
||||
@@ -148,6 +155,7 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
autoStart: r.autoStart === 1,
|
||||
serverProtocol: r.serverProtocol ?? "",
|
||||
ts6ApiKey: r.ts6ApiKey ?? "",
|
||||
serverPassword: r.serverPassword ?? "",
|
||||
identity: r.identity ?? undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
}
|
||||
|
||||
export interface SongWithUrl extends Song {
|
||||
@@ -17,7 +17,7 @@ export interface Playlist {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
@@ -26,7 +26,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -54,7 +54,7 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili";
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
|
||||
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<string | null>;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongWithUrl,
|
||||
Playlist,
|
||||
Album,
|
||||
SearchResult,
|
||||
LyricLine,
|
||||
QrCodeResult,
|
||||
AuthStatus,
|
||||
} from "./provider.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */
|
||||
function findYtDlp(): string {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "..", "bin", "yt-dlp.exe"),
|
||||
join(__dirname, "..", "..", "bin", "yt-dlp"),
|
||||
"yt-dlp",
|
||||
"yt-dlp.exe",
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (!c.includes(join("bin", "yt-dlp")) || existsSync(c)) return c;
|
||||
// For PATH entries (no directory prefix), fall through to let execFile try
|
||||
}
|
||||
return "yt-dlp";
|
||||
}
|
||||
|
||||
async function runYtDlp(args: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const binary = findYtDlp();
|
||||
const env = { ...process.env };
|
||||
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
|
||||
// yt-dlp respects these env vars natively
|
||||
}
|
||||
const { stdout } = await execFileAsync(binary, args, {
|
||||
timeout: timeoutMs,
|
||||
env,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
interface YtDlpEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
uploader?: string;
|
||||
channel?: string;
|
||||
duration?: number;
|
||||
thumbnail?: string;
|
||||
webpage_url?: string;
|
||||
url?: string;
|
||||
entries?: YtDlpEntry[];
|
||||
_type?: string;
|
||||
}
|
||||
|
||||
function entryToSong(entry: YtDlpEntry): Song {
|
||||
return {
|
||||
id: entry.id ?? "",
|
||||
name: entry.title ?? "Unknown",
|
||||
artist: entry.uploader ?? entry.channel ?? "YouTube",
|
||||
album: "YouTube",
|
||||
duration: Math.round(entry.duration ?? 0),
|
||||
coverUrl: entry.thumbnail ?? "",
|
||||
platform: "youtube",
|
||||
};
|
||||
}
|
||||
|
||||
export class YouTubeProvider implements MusicProvider {
|
||||
readonly platform = "youtube" as const;
|
||||
private quality = "bestaudio";
|
||||
|
||||
async search(query: string, limit = 5): Promise<SearchResult> {
|
||||
try {
|
||||
const raw = await runYtDlp([
|
||||
`ytsearch${limit}:${query}`,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
const songs: Song[] = lines.map((line) => {
|
||||
const entry = JSON.parse(line) as YtDlpEntry;
|
||||
return entryToSong(entry);
|
||||
});
|
||||
return { songs, playlists: [], albums: [] };
|
||||
} catch {
|
||||
return { songs: [], playlists: [], albums: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<string | null> {
|
||||
try {
|
||||
const url = `https://www.youtube.com/watch?v=${songId}`;
|
||||
const raw = await runYtDlp([
|
||||
url,
|
||||
"--get-url",
|
||||
"-f",
|
||||
"bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
], 45_000);
|
||||
const audioUrl = raw.trim().split("\n")[0];
|
||||
return audioUrl || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setQuality(quality: string): void {
|
||||
this.quality = quality;
|
||||
}
|
||||
|
||||
getQuality(): string {
|
||||
return this.quality;
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
try {
|
||||
const url = `https://www.youtube.com/watch?v=${songId}`;
|
||||
const raw = await runYtDlp([url, "--dump-json", "--no-warnings", "--quiet"]);
|
||||
const entry = JSON.parse(raw.trim()) as YtDlpEntry;
|
||||
return entryToSong(entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
try {
|
||||
const url = playlistId.startsWith("http")
|
||||
? playlistId
|
||||
: `https://www.youtube.com/playlist?list=${playlistId}`;
|
||||
const raw = await runYtDlp([
|
||||
url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
], 60_000);
|
||||
const lines = raw.trim().split("\n").filter(Boolean);
|
||||
return lines.map((line) => entryToSong(JSON.parse(line) as YtDlpEntry));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAlbumSongs(_albumId: string): Promise<Song[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLyrics(_songId: string): Promise<LyricLine[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
return { qrUrl: "", key: "" };
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(
|
||||
_key: string
|
||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
setCookie(_cookie: string): void {}
|
||||
getCookie(): string { return ""; }
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -43,6 +43,7 @@ export function createBotRouter(
|
||||
nickname,
|
||||
defaultChannel,
|
||||
channelPassword,
|
||||
serverPassword,
|
||||
autoStart,
|
||||
} = req.body;
|
||||
if (!name || !serverAddress || !nickname) {
|
||||
@@ -58,6 +59,7 @@ export function createBotRouter(
|
||||
nickname,
|
||||
defaultChannel,
|
||||
channelPassword,
|
||||
serverPassword,
|
||||
autoStart: autoStart ?? false,
|
||||
});
|
||||
res.status(201).json(bot.getStatus());
|
||||
@@ -75,10 +77,10 @@ export function createBotRouter(
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
return;
|
||||
}
|
||||
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body;
|
||||
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
|
||||
// Update in database
|
||||
botManager.updateBot(req.params.id, {
|
||||
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword,
|
||||
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from "express";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
|
||||
export function createMusicRouter(
|
||||
@@ -9,9 +10,11 @@ export function createMusicRouter(
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
|
||||
function getProvider(platform?: string): MusicProvider {
|
||||
if (platform === "bilibili") return bilibiliProvider;
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user