mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42: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
Generated
+9
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,36 +9,42 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<!-- Multi-bot selector -->
|
||||
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
|
||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||
<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 }" />
|
||||
</button>
|
||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||
<button
|
||||
<div
|
||||
v-for="bot in store.bots"
|
||||
:key="bot.id"
|
||||
class="bot-dropdown-item"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@click="selectBot(bot.id)"
|
||||
class="bot-dropdown-row"
|
||||
>
|
||||
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||||
<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-dropdown-item"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@click="selectBot(bot.id)"
|
||||
>
|
||||
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<Icon icon="mdi:cog" />
|
||||
</RouterLink>
|
||||
@@ -61,6 +67,13 @@ function selectBot(id: string) {
|
||||
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) {
|
||||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||
dropdownOpen.value = false;
|
||||
@@ -146,19 +159,28 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
padding: 8px 16px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bot-state-mini {
|
||||
font-size: 11px;
|
||||
&.playing { color: #22c55e; }
|
||||
&.paused { color: #eab308; }
|
||||
}
|
||||
|
||||
.bot-chevron {
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
@@ -205,7 +227,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
@@ -222,6 +245,12 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.bot-dropdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bot-dropdown-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -230,6 +259,21 @@ onUnmounted(() => {
|
||||
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-paused-badge,
|
||||
.bot-idle-badge,
|
||||
@@ -261,8 +305,21 @@ onUnmounted(() => {
|
||||
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 {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
&:hover { opacity: 1; }
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span
|
||||
class="platform-badge"
|
||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
|
||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
|
||||
: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' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
|
||||
</div>
|
||||
<div class="song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -76,6 +76,10 @@
|
||||
<label>频道密码(可选)</label>
|
||||
<input v-model="editForm.channelPassword" class="input" type="password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器密码(可选)</label>
|
||||
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
||||
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
||||
@@ -108,6 +112,10 @@
|
||||
<label>默认频道(可选)</label>
|
||||
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器密码(可选)</label>
|
||||
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||
</div>
|
||||
<button class="btn-primary" @click="createBot">创建</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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<string | null>(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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user