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:
saopig1andClaude Sonnet 4.6 committed 2026-04-09 11:58:07 +08:00
1 parent b20ddf2603
commit d5d6abeb1e
16 files changed
+391 -40

No files matched your search

+9 -1
View File
@@ -25,7 +25,8 @@
"pino": "^10.3.1", "pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1", "ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"ws": "^8.20.0" "ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
@@ -6268,6 +6269,13 @@
"engines": { "engines": {
"node": ">= 4.0.0" "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"
} }
} }
} }
+2 -1
View File
@@ -30,7 +30,8 @@
"pino": "^10.3.1", "pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1", "ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"ws": "^8.20.0" "ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string; name: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili"; platform: "netease" | "qq" | "bilibili" | "youtube";
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
+7 -1
View File
@@ -23,6 +23,7 @@ export interface BotInstanceOptions {
neteaseProvider: MusicProvider; neteaseProvider: MusicProvider;
qqProvider: MusicProvider; qqProvider: MusicProvider;
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -51,6 +52,7 @@ export class BotInstance extends EventEmitter {
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -65,6 +67,7 @@ export class BotInstance extends EventEmitter {
this.neteaseProvider = options.neteaseProvider; this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider; this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider; this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); 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 === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
private getProvider(flags: Set<string>): MusicProvider { private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider; if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider; if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider;
return this.neteaseProvider; return this.neteaseProvider;
} }
@@ -477,6 +482,7 @@ export class BotInstance extends EventEmitter {
`${p}play <song> — Search and play`, `${p}play <song> — Search and play`,
`${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}add <song> — Add to queue`, `${p}add <song> — Add to queue`,
`${p}pause/resume — Pause/resume`, `${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`, `${p}next/prev — Next/previous`,
+13
View File
@@ -4,6 +4,7 @@ import {
type BotInstanceOptions, type BotInstanceOptions,
} from "./instance.js"; } from "./instance.js";
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js"; import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js"; import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
@@ -23,6 +24,8 @@ export interface CreateBotParams {
serverProtocol?: ServerProtocol; serverProtocol?: ServerProtocol;
/** API key for TS6 HTTP Query (port 10080/10443). */ /** API key for TS6 HTTP Query (port 10080/10443). */
ts6ApiKey?: string; ts6ApiKey?: string;
/** Password required to join the TS server. */
serverPassword?: string;
} }
export class BotManager { export class BotManager {
@@ -30,6 +33,7 @@ export class BotManager {
private neteaseProvider: MusicProvider; private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider; private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider; private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -45,6 +49,7 @@ export class BotManager {
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider; this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider; this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.database = database; this.database = database;
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
@@ -63,12 +68,14 @@ export class BotManager {
nickname: params.nickname, nickname: params.nickname,
defaultChannel: params.defaultChannel, defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword, channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol, serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey, ts6ApiKey: params.ts6ApiKey,
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -87,6 +94,7 @@ export class BotManager {
autoStart: params.autoStart ?? false, autoStart: params.autoStart ?? false,
serverProtocol: params.serverProtocol ?? "", serverProtocol: params.serverProtocol ?? "",
ts6ApiKey: params.ts6ApiKey ?? "", ts6ApiKey: params.ts6ApiKey ?? "",
serverPassword: params.serverPassword ?? "",
}); });
this.logger.info({ botId: id, name: params.name }, "Bot instance created"); this.logger.info({ botId: id, name: params.name }, "Bot instance created");
@@ -118,6 +126,7 @@ export class BotManager {
channelPassword: params.channelPassword ?? existing.channelPassword, channelPassword: params.channelPassword ?? existing.channelPassword,
serverProtocol: params.serverProtocol ?? existing.serverProtocol, serverProtocol: params.serverProtocol ?? existing.serverProtocol,
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey, ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
serverPassword: params.serverPassword ?? existing.serverPassword,
}); });
// Update in-memory name immediately (other fields need reconnect) // Update in-memory name immediately (other fields need reconnect)
const bot = this.bots.get(id); const bot = this.bots.get(id);
@@ -157,12 +166,14 @@ export class BotManager {
nickname: saved.nickname, nickname: saved.nickname,
defaultChannel: saved.defaultChannel || undefined, defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined, channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined, ts6ApiKey: saved.ts6ApiKey || undefined,
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -204,12 +215,14 @@ export class BotManager {
identity: saved.identity || undefined, identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined, defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined, channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined, ts6ApiKey: saved.ts6ApiKey || undefined,
}, },
neteaseProvider: this.neteaseProvider, neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider, qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider, bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+2
View File
@@ -62,6 +62,7 @@ describe("database", () => {
autoStart: true, autoStart: true,
serverProtocol: "", serverProtocol: "",
ts6ApiKey: "", ts6ApiKey: "",
serverPassword: "",
}; };
botDb.saveBotInstance(instance); botDb.saveBotInstance(instance);
@@ -90,6 +91,7 @@ describe("database", () => {
autoStart: false, autoStart: false,
serverProtocol: "", serverProtocol: "",
ts6ApiKey: "", ts6ApiKey: "",
serverPassword: "",
}); });
expect(botDb.deleteBotInstance("bot1")).toBe(true); expect(botDb.deleteBotInstance("bot1")).toBe(true);
+11 -3
View File
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
songName: string; songName: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili"; platform: "netease" | "qq" | "bilibili" | "youtube";
coverUrl: string; coverUrl: string;
} }
@@ -28,6 +28,8 @@ export interface BotInstance {
serverProtocol: string; serverProtocol: string;
/** API key for TS6 HTTP Query */ /** API key for TS6 HTTP Query */
ts6ApiKey: string; ts6ApiKey: string;
/** Password to join the TS server (server password) */
serverPassword: string;
identity?: string; identity?: string;
} }
@@ -53,6 +55,9 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("ts6ApiKey")) { if (!names.includes("ts6ApiKey")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''"); 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 { function initTables(db: Database.Database): void {
@@ -80,6 +85,7 @@ function initTables(db: Database.Database): void {
autoStart INTEGER NOT NULL DEFAULT 0, autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '', serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT identity TEXT
); );
`); `);
@@ -101,8 +107,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`); `);
const upsertInstance = db.prepare(` const upsertInstance = db.prepare(`
INSERT INTO bot_instances (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, @identity) VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
serverAddress = excluded.serverAddress, serverAddress = excluded.serverAddress,
@@ -113,6 +119,7 @@ export function createDatabase(dbPath: string): BotDatabase {
autoStart = excluded.autoStart, autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol, serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey, ts6ApiKey = excluded.ts6ApiKey,
serverPassword = excluded.serverPassword,
identity = excluded.identity identity = excluded.identity
`); `);
@@ -148,6 +155,7 @@ export function createDatabase(dbPath: string): BotDatabase {
autoStart: r.autoStart === 1, autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "", serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "", ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
identity: r.identity ?? undefined, identity: r.identity ?? undefined,
})); }));
}, },
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string; album: string;
duration: number; // seconds duration: number; // seconds
coverUrl: string; coverUrl: string;
platform: "netease" | "qq" | "bilibili"; platform: "netease" | "qq" | "bilibili" | "youtube";
} }
export interface SongWithUrl extends Song { export interface SongWithUrl extends Song {
@@ -17,7 +17,7 @@ export interface Playlist {
name: string; name: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili"; platform: "netease" | "qq" | "bilibili" | "youtube";
} }
export interface Album { export interface Album {
@@ -26,7 +26,7 @@ export interface Album {
artist: string; artist: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili"; platform: "netease" | "qq" | "bilibili" | "youtube";
} }
export interface LyricLine { export interface LyricLine {
@@ -54,7 +54,7 @@ export interface AuthStatus {
} }
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili"; readonly platform: "netease" | "qq" | "bilibili" | "youtube";
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>; getSongUrl(songId: string, quality?: string): Promise<string | null>;
+184
View File
@@ -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
View File
@@ -43,6 +43,7 @@ export function createBotRouter(
nickname, nickname,
defaultChannel, defaultChannel,
channelPassword, channelPassword,
serverPassword,
autoStart, autoStart,
} = req.body; } = req.body;
if (!name || !serverAddress || !nickname) { if (!name || !serverAddress || !nickname) {
@@ -58,6 +59,7 @@ export function createBotRouter(
nickname, nickname,
defaultChannel, defaultChannel,
channelPassword, channelPassword,
serverPassword,
autoStart: autoStart ?? false, autoStart: autoStart ?? false,
}); });
res.status(201).json(bot.getStatus()); res.status(201).json(bot.getStatus());
@@ -75,10 +77,10 @@ export function createBotRouter(
res.status(404).json({ error: "Bot not found" }); res.status(404).json({ error: "Bot not found" });
return; return;
} }
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body; const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
// Update in database // Update in database
botManager.updateBot(req.params.id, { botManager.updateBot(req.params.id, {
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
+3
View File
@@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
export function createMusicRouter( export function createMusicRouter(
@@ -9,9 +10,11 @@ export function createMusicRouter(
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider { function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider; if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
+82 -25
View File
@@ -9,36 +9,42 @@
</div> </div>
<div class="nav-right"> <div class="nav-right">
<!-- Multi-bot selector --> <!-- Bot selector (always shown when at least one bot exists) -->
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef"> <div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen"> <button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" /> <span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span> <span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" /> <Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button> </button>
<div v-if="dropdownOpen" class="bot-dropdown"> <div v-if="dropdownOpen" class="bot-dropdown">
<button <div
v-for="bot in store.bots" v-for="bot in store.bots"
:key="bot.id" :key="bot.id"
class="bot-dropdown-item" class="bot-dropdown-row"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
> >
<span class="bot-dot" :class="{ online: bot.connected }" /> <button
<span class="bot-dropdown-name">{{ bot.name }}</span> class="bot-dropdown-item"
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span> :class="{ active: bot.id === store.activeBotId }"
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span> @click="selectBot(bot.id)"
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span> >
<span v-else class="bot-offline-badge">离线</span> <span class="bot-dot" :class="{ online: bot.connected }" />
</button> <span class="bot-dropdown-name">{{ bot.name }}</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
<Icon icon="mdi:link-variant" />
</button>
</div>
<div class="bot-dropdown-divider" />
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div> </div>
</div> </div>
<!-- Single bot status (original behavior) -->
<div v-else-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
</div>
<RouterLink to="/settings" class="settings-btn"> <RouterLink to="/settings" class="settings-btn">
<Icon icon="mdi:cog" /> <Icon icon="mdi:cog" />
</RouterLink> </RouterLink>
@@ -61,6 +67,13 @@ function selectBot(id: string) {
dropdownOpen.value = false; dropdownOpen.value = false;
} }
function copyBotLink(id: string) {
const url = `${window.location.origin}/bot/${id}`;
navigator.clipboard.writeText(url).then(() => {
dropdownOpen.value = false;
});
}
function onClickOutside(e: MouseEvent) { function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) { if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
dropdownOpen.value = false; dropdownOpen.value = false;
@@ -146,19 +159,28 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 8px 16px;
background: var(--hover-bg); background: var(--hover-bg);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
font-size: 13px; font-size: 14px;
font-weight: 500; font-weight: 600;
transition: background var(--transition-fast); min-height: 36px;
border: 1px solid var(--border-color);
transition: background var(--transition-fast), border-color var(--transition-fast);
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: var(--bg-card); background: var(--bg-card);
border-color: var(--color-primary);
} }
} }
.bot-state-mini {
font-size: 11px;
&.playing { color: #22c55e; }
&.paused { color: #eab308; }
}
.bot-chevron { .bot-chevron {
font-size: 16px; font-size: 16px;
opacity: 0.5; opacity: 0.5;
@@ -205,7 +227,8 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; flex: 1;
min-width: 0;
padding: 8px 12px; padding: 8px 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
@@ -222,6 +245,12 @@ onUnmounted(() => {
} }
} }
.bot-dropdown-row {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name { .bot-dropdown-name {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -230,6 +259,21 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.bot-link-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-playing-badge, .bot-playing-badge,
.bot-paused-badge, .bot-paused-badge,
.bot-idle-badge, .bot-idle-badge,
@@ -261,8 +305,21 @@ onUnmounted(() => {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.bot-dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.bot-dropdown-hint {
padding: 4px 12px 6px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.settings-btn { .settings-btn {
font-size: 20px; font-size: 22px;
opacity: 0.6; opacity: 0.6;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
&:hover { opacity: 1; } &:hover { opacity: 1; }
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span> <span class="song-name">{{ song.name }}</span>
<span <span
class="platform-badge" class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'" :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -118,6 +118,11 @@ function formatDuration(seconds: number): string {
color: #00a1d6; color: #00a1d6;
} }
.badge-youtube {
background: rgba(255, 0, 0, 0.12);
color: #ff0000;
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+6
View File
@@ -38,6 +38,12 @@ const router = createRouter({
name: 'setup', name: 'setup',
component: () => import('../views/Setup.vue'), 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'),
},
], ],
}); });
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="redirect-page">
<span v-if="notFound">机器人不存在或未加载</span>
<span v-else>正在跳转...</span>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
const route = useRoute();
const router = useRouter();
const store = usePlayerStore();
const notFound = ref(false);
onMounted(async () => {
const botId = route.params.id as string;
if (!store.bots.length) {
await store.fetchBots();
}
const bot = store.bots.find((b) => b.id === botId);
if (bot) {
store.setActiveBotId(botId);
router.replace('/');
} else {
notFound.value = true;
}
});
</script>
<style scoped>
.redirect-page {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
font-size: 16px;
color: var(--text-secondary);
}
</style>
+14
View File
@@ -76,6 +76,10 @@
<label>频道密码(可选)</label> <label>频道密码(可选)</label>
<input v-model="editForm.channelPassword" class="input" type="password" /> <input v-model="editForm.channelPassword" class="input" type="password" />
</div> </div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button> <button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button> <button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
@@ -108,6 +112,10 @@
<label>默认频道(可选)</label> <label>默认频道(可选)</label>
<input v-model="newBotChannel" class="input" placeholder="音乐频道" /> <input v-model="newBotChannel" class="input" placeholder="音乐频道" />
</div> </div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<button class="btn-primary" @click="createBot">创建</button> <button class="btn-primary" @click="createBot">创建</button>
</div> </div>
</section> </section>
@@ -394,6 +402,7 @@ const newBotServer = ref('');
const newBotPort = ref(9987); const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot'); const newBotNickname = ref('MusicBot');
const newBotChannel = ref(''); const newBotChannel = ref('');
const newBotServerPassword = ref('');
// Edit bot // Edit bot
const editingBot = ref<string | null>(null); const editingBot = ref<string | null>(null);
@@ -404,6 +413,7 @@ const editForm = reactive({
nickname: '', nickname: '',
defaultChannel: '', defaultChannel: '',
channelPassword: '', channelPassword: '',
serverPassword: '',
}); });
const neteaseCookie = ref(''); const neteaseCookie = ref('');
@@ -559,6 +569,7 @@ async function createBot() {
serverPort: newBotPort.value || 9987, serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value, nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined, defaultChannel: newBotChannel.value || undefined,
serverPassword: newBotServerPassword.value || undefined,
autoStart: false, autoStart: false,
}); });
newBotName.value = ''; newBotName.value = '';
@@ -566,6 +577,7 @@ async function createBot() {
newBotPort.value = 9987; newBotPort.value = 9987;
newBotNickname.value = 'MusicBot'; newBotNickname.value = 'MusicBot';
newBotChannel.value = ''; newBotChannel.value = '';
newBotServerPassword.value = '';
await store.fetchBots(); await store.fetchBots();
} catch { } catch {
// Ignore // Ignore
@@ -598,6 +610,7 @@ async function openEditBot(bot: any) {
editForm.nickname = res.data.nickname ?? ''; editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? ''; editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelPassword = res.data.channelPassword ?? ''; editForm.channelPassword = res.data.channelPassword ?? '';
editForm.serverPassword = res.data.serverPassword ?? '';
} catch { } catch {
// Config not found — use defaults // Config not found — use defaults
editForm.serverAddress = ''; editForm.serverAddress = '';
@@ -605,6 +618,7 @@ async function openEditBot(bot: any) {
editForm.nickname = bot.name; editForm.nickname = bot.name;
editForm.defaultChannel = ''; editForm.defaultChannel = '';
editForm.channelPassword = ''; editForm.channelPassword = '';
editForm.serverPassword = '';
} }
} }