From d5d6abeb1e241323bf8e36d6307ce5ea636b11da Mon Sep 17 00:00:00 2001 From: saopig1 Date: Thu, 9 Apr 2026 11:58:07 +0800 Subject: [PATCH] feat: implement server password login, YouTube source, and improved bot selector UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **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 --- package-lock.json | 10 +- package.json | 3 +- src/audio/queue.ts | 2 +- src/bot/instance.ts | 8 +- src/bot/manager.ts | 13 +++ src/data/database.test.ts | 2 + src/data/database.ts | 14 ++- src/music/provider.ts | 8 +- src/music/youtube.ts | 184 ++++++++++++++++++++++++++++++++ src/web/api/bot.ts | 6 +- src/web/api/music.ts | 3 + web/src/components/Navbar.vue | 107 ++++++++++++++----- web/src/components/SongCard.vue | 9 +- web/src/router/index.ts | 6 ++ web/src/views/BotRedirect.vue | 42 ++++++++ web/src/views/Settings.vue | 14 +++ 16 files changed, 391 insertions(+), 40 deletions(-) create mode 100644 src/music/youtube.ts create mode 100644 web/src/views/BotRedirect.vue diff --git a/package-lock.json b/package-lock.json index 70f217b..468387e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "pino": "^10.3.1", "ts3-nodejs-library": "^3.5.1", "tweetnacl": "^1.0.3", - "ws": "^8.20.0" + "ws": "^8.20.0", + "yt-dlp-wrap": "^2.3.12" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -6268,6 +6269,13 @@ "engines": { "node": ">= 4.0.0" } + }, + "node_modules/yt-dlp-wrap": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz", + "integrity": "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" } } } diff --git a/package.json b/package.json index 694d5fc..1123e8d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "pino": "^10.3.1", "ts3-nodejs-library": "^3.5.1", "tweetnacl": "^1.0.3", - "ws": "^8.20.0" + "ws": "^8.20.0", + "yt-dlp-wrap": "^2.3.12" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 4e58ca0..0416522 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -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 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index e1ce0e5..f31fa71 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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): 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 — Search and play`, `${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}pause/resume — Pause/resume`, `${p}next/prev — Next/previous`, diff --git a/src/bot/manager.ts b/src/bot/manager.ts index bb3cac6..08b4c65 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -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, diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 8807797..38b140d 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -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); diff --git a/src/data/database.ts b/src/data/database.ts index ca66970..8e193e1 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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, })); }, diff --git a/src/music/provider.ts b/src/music/provider.ts index 90b5f87..55a2c76 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -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; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/music/youtube.ts b/src/music/youtube.ts new file mode 100644 index 0000000..339edf0 --- /dev/null +++ b/src/music/youtube.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return []; + } + + async getAlbumSongs(_albumId: string): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + return { qrUrl: "", key: "" }; + } + + async checkQrCodeStatus( + _key: string + ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + return "expired"; + } + + setCookie(_cookie: string): void {} + getCookie(): string { return ""; } + + async getAuthStatus(): Promise { + return { loggedIn: true, nickname: "YouTube (yt-dlp)" }; + } +} diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 2d4d51b..e60bece 100644 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -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) { diff --git a/src/web/api/music.ts b/src/web/api/music.ts index e1ef73b..3690674 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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; } diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 006a51e..f381595 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -9,36 +9,42 @@ @@ -118,6 +118,11 @@ function formatDuration(seconds: number): string { color: #00a1d6; } +.badge-youtube { + background: rgba(255, 0, 0, 0.12); + color: #ff0000; +} + .song-artist { font-size: 12px; color: var(--text-secondary); diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 66da02a..356d57f 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -38,6 +38,12 @@ const router = createRouter({ name: 'setup', component: () => import('../views/Setup.vue'), }, + { + // Per-bot URL: /bot/:id — sets active bot then redirects to home + path: '/bot/:id', + name: 'bot', + component: () => import('../views/BotRedirect.vue'), + }, ], }); diff --git a/web/src/views/BotRedirect.vue b/web/src/views/BotRedirect.vue new file mode 100644 index 0000000..3378ac4 --- /dev/null +++ b/web/src/views/BotRedirect.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 474f8a5..7bb2128 100644 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -76,6 +76,10 @@ +
+ + +
+
+ + +
@@ -394,6 +402,7 @@ const newBotServer = ref(''); const newBotPort = ref(9987); const newBotNickname = ref('MusicBot'); const newBotChannel = ref(''); +const newBotServerPassword = ref(''); // Edit bot const editingBot = ref(null); @@ -404,6 +413,7 @@ const editForm = reactive({ nickname: '', defaultChannel: '', channelPassword: '', + serverPassword: '', }); const neteaseCookie = ref(''); @@ -559,6 +569,7 @@ async function createBot() { serverPort: newBotPort.value || 9987, nickname: newBotNickname.value || newBotName.value, defaultChannel: newBotChannel.value || undefined, + serverPassword: newBotServerPassword.value || undefined, autoStart: false, }); newBotName.value = ''; @@ -566,6 +577,7 @@ async function createBot() { newBotPort.value = 9987; newBotNickname.value = 'MusicBot'; newBotChannel.value = ''; + newBotServerPassword.value = ''; await store.fetchBots(); } catch { // Ignore @@ -598,6 +610,7 @@ async function openEditBot(bot: any) { editForm.nickname = res.data.nickname ?? ''; editForm.defaultChannel = res.data.defaultChannel ?? ''; editForm.channelPassword = res.data.channelPassword ?? ''; + editForm.serverPassword = res.data.serverPassword ?? ''; } catch { // Config not found — use defaults editForm.serverAddress = ''; @@ -605,6 +618,7 @@ async function openEditBot(bot: any) { editForm.nickname = bot.name; editForm.defaultChannel = ''; editForm.channelPassword = ''; + editForm.serverPassword = ''; } }