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
+380
-29
No files matched your search
Generated
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,22 @@
|
|||||||
</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-row"
|
||||||
|
>
|
||||||
|
<button
|
||||||
class="bot-dropdown-item"
|
class="bot-dropdown-item"
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
@click="selectBot(bot.id)"
|
@click="selectBot(bot.id)"
|
||||||
@@ -31,12 +36,13 @@
|
|||||||
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||||||
<span v-else class="bot-offline-badge">离线</span>
|
<span v-else class="bot-offline-badge">离线</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
|
||||||
|
<Icon icon="mdi:link-variant" />
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<RouterLink to="/settings" class="settings-btn">
|
<RouterLink to="/settings" class="settings-btn">
|
||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user