feat: add local audio upload playback

This commit is contained in:
Fa1nttt committed 2026-06-30 14:08:42 +08:00
1 parent 4e148302fc
commit e12cbf8863
17 files changed
+750 -30

No files matched your search

+3 -1
View File
@@ -26,6 +26,7 @@
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
@@ -629,10 +630,11 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
+1 -1
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
+80 -2
View File
@@ -35,6 +35,7 @@ export interface BotInstanceOptions {
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
localProvider?: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -67,6 +68,7 @@ export class BotInstance extends EventEmitter {
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -95,6 +97,7 @@ export class BotInstance extends EventEmitter {
this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -147,6 +150,43 @@ export class BotInstance extends EventEmitter {
});
}
isLocalAudioEnabled(): boolean {
return this.config.localAudioEnabled !== false;
}
cleanupQueuedLocalSongs(reason: string): void {
this.cleanupLocalSongs(this.queue.list(), reason);
}
private isSameSong(a: QueuedSong | Song | null | undefined, b: QueuedSong | Song | null | undefined): boolean {
return !!a && !!b && a.platform === b.platform && a.id === b.id;
}
private cleanupLocalSong(song: QueuedSong | Song | null | undefined, reason: string): void {
if (!song || song.platform !== "local") return;
const cleanupProvider = this.localProvider as MusicProvider & {
deleteSong?: (songId: string) => Promise<boolean>;
};
if (typeof cleanupProvider.deleteSong !== "function") return;
cleanupProvider.deleteSong(song.id).then((deleted) => {
if (deleted) {
this.logger.info({ songId: song.id, name: song.name, reason }, "Deleted local audio file");
}
}).catch((err) => {
this.logger.warn({ err, songId: song.id, name: song.name, reason }, "Failed to delete local audio file");
});
}
private cleanupLocalSongs(songs: Array<QueuedSong | Song>, reason: string): void {
const seen = new Set<string>();
for (const song of songs) {
if (song.platform !== "local" || seen.has(song.id)) continue;
seen.add(song.id);
this.cleanupLocalSong(song, reason);
}
}
private setupTsEvents(): void {
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
this.handleTextMessage(msg).catch((err) => {
@@ -159,8 +199,11 @@ export class BotInstance extends EventEmitter {
// completed (hanging handshake → 60s library idle timeout) and
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
const queued = this.queue.list();
this.connected = false;
this.player.stop();
this.cleanupLocalSongs(queued, "disconnected");
this.queue.clear();
// A lifecycle change must not leave a stale auto-resume armed.
this.autoPaused = false;
// Only emit externally once per lifecycle so clients don't see a
@@ -241,7 +284,10 @@ export class BotInstance extends EventEmitter {
disconnect(): void {
this._cancelIdleTimer();
const queued = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(queued, "disconnected");
this.queue.clear();
this.connected = false;
if (!this.disconnectEmitted) {
this.disconnectEmitted = true;
@@ -482,9 +528,10 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
@@ -506,6 +553,10 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
return false;
}
if (song.platform === "local" && !this.isLocalAudioEnabled()) {
this.logger.warn({ songId: song.id, name: song.name }, "Local audio playback disabled — refusing track");
return false;
}
// Clear any accumulated skip votes — every fresh track starts with a
// clean slate, regardless of which code path loaded it (cmdPlay,
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
@@ -621,6 +672,11 @@ export class BotInstance extends EventEmitter {
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const song0 = song!;
const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) {
this.player.stop();
this.cleanupLocalSong(previous, "replaced");
}
this.queue.clear();
this.disableFmMode();
this.queue.add({ ...song0 });
@@ -705,7 +761,9 @@ export class BotInstance extends EventEmitter {
}
private cmdStop(): string {
const queued = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(queued, "stopped");
this.autoPaused = false;
this.queue.clear();
this.disableFmMode();
@@ -764,7 +822,9 @@ export class BotInstance extends EventEmitter {
}
private cmdClear(): string {
const queued = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(queued, "queue_cleared");
this.queue.clear();
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
@@ -779,6 +839,7 @@ export class BotInstance extends EventEmitter {
if (isNaN(index) || index < 0) return "Usage: !remove <number>";
const removed = this.queue.remove(index);
if (!removed) return "Invalid position";
this.cleanupLocalSong(removed, "removed_from_queue");
this.emit("stateChange");
return `Removed: ${removed.name}`;
}
@@ -838,6 +899,9 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
const previousQueue = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
@@ -873,6 +937,9 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
const previousQueue = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
@@ -902,6 +969,9 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0)
return "No FM songs available (need to login first)";
const previousQueue = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
@@ -935,6 +1005,9 @@ export class BotInstance extends EventEmitter {
filtered = result.songs.slice(0, 20);
}
const previousQueue = this.queue.list();
this.player.stop();
this.cleanupLocalSongs(previousQueue, "queue_replaced");
this.queue.clear();
this.disableFmMode();
for (const song of filtered) {
@@ -1049,11 +1122,12 @@ export class BotInstance extends EventEmitter {
*/
async playNext(maxRetries = 3): Promise<boolean> {
if (this.isAdvancing || !this.connected) return false;
const previous = this.queue.current();
this.isAdvancing = true;
let started = false;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
let started = false;
if (next) {
started = await this.resolveAndPlay(next);
if (!started) {
@@ -1093,6 +1167,10 @@ export class BotInstance extends EventEmitter {
this.emit("stateChange");
return started;
} finally {
const current = started ? this.queue.current() : null;
if (previous && !this.isSameSong(previous, current)) {
this.cleanupLocalSong(previous, "playback_finished");
}
this.isAdvancing = false;
}
}
+7 -1
View File
@@ -74,6 +74,7 @@ export class BotManager extends EventEmitter {
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -90,13 +91,15 @@ export class BotManager extends EventEmitter {
logger: Logger,
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string
configPath: string,
localProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
this.database = database;
this.config = config;
this.logger = logger;
@@ -127,6 +130,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -253,6 +257,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -305,6 +310,7 @@ export class BotManager extends EventEmitter {
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
+3
View File
@@ -22,6 +22,8 @@ export interface BotConfig {
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000"
@@ -50,6 +52,7 @@ export function getDefaultConfig(): BotConfig {
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
idleTimeoutMinutes: 0,
localAudioEnabled: true,
publicUrl: "",
trustProxy: false,
guestMode: {
+1 -1
View File
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
coverUrl: string;
}
+6 -1
View File
@@ -7,6 +7,7 @@ import { createApiServerManager } from "./music/api-server.js";
import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
@@ -25,6 +26,7 @@ const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
@@ -54,6 +56,7 @@ async function main() {
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -75,7 +78,8 @@ async function main() {
logger,
avatarStore,
permissions,
CONFIG_PATH
CONFIG_PATH,
localProvider
);
await botManager.loadSavedBots();
@@ -85,6 +89,7 @@ async function main() {
neteaseProvider,
qqProvider,
bilibiliProvider,
localProvider,
database: db,
avatarStore,
config,
+252
View File
@@ -0,0 +1,252 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
import type {
Album,
AuthStatus,
LyricLine,
MusicProvider,
Playlist,
PlaylistDetail,
QrCodeResult,
SearchResult,
Song,
SongUrlResult,
} from "./provider.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
const AUDIO_EXTENSIONS = new Set([
".mp3",
".flac",
".wav",
".m4a",
".aac",
".ogg",
".opus",
".webm",
".wma",
".alac",
".aiff",
".ape",
]);
interface LocalSongRecord extends Song {
filePath: string;
originalName: string;
uploadedAt: string;
size: number;
mimeType: string;
}
function safeFileName(name: string): string {
const base = path.basename(name || "audio");
return base
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160) || "audio";
}
function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
function isSupportedAudio(name: string, mimeType?: string): boolean {
const ext = path.extname(name).toLowerCase();
if (AUDIO_EXTENSIONS.has(ext)) return true;
return !!mimeType && (mimeType.startsWith("audio/") || mimeType === "video/webm");
}
async function probeDurationSeconds(filePath: string): Promise<number> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
});
}
export class LocalMusicProvider implements MusicProvider {
readonly platform = "local" as const;
private readonly uploadDir: string;
private readonly indexPath: string;
private records: LocalSongRecord[] = [];
constructor(uploadDir: string) {
this.uploadDir = uploadDir;
this.indexPath = path.join(uploadDir, "index.json");
mkdirSync(uploadDir, { recursive: true });
this.loadIndex();
}
private loadIndex(): void {
try {
const raw = readFileSync(this.indexPath, "utf8");
const parsed = JSON.parse(raw) as LocalSongRecord[];
this.records = Array.isArray(parsed)
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
: [];
} catch {
this.records = [];
}
}
private saveIndex(): void {
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
}
async uploadAudio(input: {
buffer: Buffer;
originalName: string;
mimeType?: string;
}): Promise<Song> {
const originalName = safeFileName(input.originalName || "audio");
if (!isSupportedAudio(originalName, input.mimeType)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm");
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const ext = path.extname(originalName).toLowerCase() || ".audio";
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
mimeType: input.mimeType || "application/octet-stream",
};
this.records.unshift(song);
this.saveIndex();
return this.toSong(song);
}
private toSong(record: LocalSongRecord): Song {
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
return song;
}
async search(query: string, limit = 20): Promise<SearchResult> {
const q = query.trim().toLowerCase();
const songs = this.records
.filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit)
.map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
const record = this.records.find((r) => r.id === songId);
if (!record || !existsSync(record.filePath)) return null;
return { url: record.filePath };
}
async getSongDetail(songId: string): Promise<Song | null> {
const record = this.records.find((r) => r.id === songId);
return record && existsSync(record.filePath) ? this.toSong(record) : null;
}
async deleteSong(songId: string): Promise<boolean> {
const index = this.records.findIndex((r) => r.id === songId);
if (index < 0) return false;
const [record] = this.records.splice(index, 1);
this.saveIndex();
if (record?.filePath) {
rmSync(record.filePath, { force: true });
}
return true;
}
setQuality(_quality: string): void {
// 本地文件按原始音质播放。
}
getQuality(): string {
return "original";
}
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
return [];
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
throw new Error("Local music does not require login");
}
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {
// no-op
}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "本地音乐" };
}
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
return null;
}
}
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface LyricLine {
@@ -72,7 +72,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
+5 -1
View File
@@ -36,6 +36,7 @@ export function createBotRouter(
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
@@ -44,7 +45,7 @@ export function createBotRouter(
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -53,9 +54,11 @@ export function createBotRouter(
}
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
const hasLocalAudioEnabled = typeof localAudioEnabled === "boolean";
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) {
@@ -100,6 +103,7 @@ export function createBotRouter(
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
+74 -3
View File
@@ -1,25 +1,85 @@
import { Router } from "express";
import express, { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import type { BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
export function createMusicRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
bilibiliProvider: MusicProvider,
logger: Logger
logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
function isLocalAudioEnabled(): boolean {
return config?.localAudioEnabled !== false;
}
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
router.post(
"/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
(_req, res, next) => {
if (!isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
next();
},
express.raw({
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
async (req, res) => {
try {
if (!localProvider) {
res.status(501).json({ error: "Local upload is not configured" });
return;
}
const uploadCapable = localProvider as MusicProvider & {
uploadAudio?: (input: { buffer: Buffer; originalName: string; mimeType?: string }) => Promise<unknown>;
};
if (typeof uploadCapable.uploadAudio !== "function") {
res.status(501).json({ error: "Local upload is not supported" });
return;
}
if (!Buffer.isBuffer(req.body)) {
res.status(400).json({ error: "raw audio body is required" });
return;
}
const headerName = req.header("x-filename") || req.header("x-file-name") || "audio";
let originalName = headerName;
try {
originalName = decodeURIComponent(headerName);
} catch {
// Keep the raw header value if it is not URI encoded.
}
const song = await uploadCapable.uploadAudio({
buffer: req.body,
originalName,
mimeType: req.header("content-type") || undefined,
});
res.json({ song });
} catch (err) {
logger.warn({ err }, "Local audio upload failed");
res.status(400).json({ error: (err as Error).message });
}
},
);
router.get("/search", async (req, res) => {
try {
const { q, platform, limit } = req.query;
@@ -27,6 +87,10 @@ export function createMusicRouter(
res.status(400).json({ error: "q (query) is required" });
return;
}
if (platform === "local" && !isLocalAudioEnabled()) {
res.json({ songs: [], playlists: [], albums: [] });
return;
}
const provider = getProvider(platform as string);
const result = await provider.search(
q as string,
@@ -47,16 +111,18 @@ export function createMusicRouter(
return;
}
const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult, bilibiliResult] = await Promise.allSettled([
const [neteaseResult, qqResult, bilibiliResult, localResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
bilibiliProvider.search(q as string, parsedLimit),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
]);
const songs = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
...(localResult.status === "fulfilled" ? localResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
@@ -76,6 +142,10 @@ export function createMusicRouter(
router.get("/song/:id", async (req, res) => {
try {
if (req.query.platform === "local" && !isLocalAudioEnabled()) {
res.status(403).json({ error: "本地音频播放已关闭" });
return;
}
const provider = getProvider(req.query.platform as string);
const song = await provider.getSongDetail(req.params.id);
if (!song) {
@@ -215,6 +285,7 @@ export function createMusicRouter(
netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
});
});
+52 -4
View File
@@ -42,6 +42,16 @@ export function createPlayerRouter(
return "";
};
function isLocalAudioDisabled(bot: any, platform: unknown): boolean {
return platform === "local" &&
typeof bot.isLocalAudioEnabled === "function" &&
!bot.isLocalAudioEnabled();
}
function rejectDisabledLocalAudio(res: any): void {
res.status(403).json({ error: "本地音频播放已关闭" });
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
@@ -100,8 +110,12 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
@@ -269,16 +283,21 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId);
@@ -311,6 +330,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
@@ -356,14 +376,19 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId);
@@ -389,6 +414,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
@@ -432,7 +458,13 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
const queue = bot.getQueueManager();
bot.getPlayer().stop();
bot.cleanupQueuedLocalSongs?.("queue_replaced");
queue.clear();
queue.add(song);
queue.play();
@@ -460,6 +492,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the queue mutation + playback so concurrent requests can't
// interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
@@ -504,6 +540,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the insert-after-current + promote + playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
@@ -533,6 +573,10 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
if (isLocalAudioDisabled(bot, song.platform)) {
rejectDisabledLocalAudio(res);
return;
}
// Serialize the queue mutation + (possible) playback so concurrent
// requests can't interleave (audible track must match queue.currentIndex).
const body = await bot.runExclusive(async () => {
@@ -561,8 +605,12 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
if (isLocalAudioDisabled(bot, platform)) {
rejectDisabledLocalAudio(res);
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local"
? platform
: "netease"
);
+2 -1
View File
@@ -38,6 +38,7 @@ export interface WebServerOptions {
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
localProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
@@ -123,7 +124,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
+7 -2
View File
@@ -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' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
@@ -135,6 +135,11 @@ function formatDuration(seconds: number): string {
color: var(--brand-youtube);
}
.badge-local {
background: var(--color-primary-10);
color: var(--color-primary);
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local';
}
export type Source = 'netease' | 'qq';
+230 -7
View File
@@ -16,6 +16,41 @@
autofocus
/>
</div>
<div
v-if="localAudioEnabled"
class="local-upload"
:class="{ dragging: isDragging, uploading }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
</div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }}
</button>
<input
ref="fileInput"
class="file-input"
type="file"
multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
@change="handleFileSelect"
/>
</div>
<div v-else class="local-upload disabled">
<Icon icon="mdi:music-off" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">本地音频播放已关闭</div>
<div class="upload-subtitle">管理员可在「设置 → 行为设置 → 本地音频播放」中开启。</div>
</div>
</div>
<div v-if="uploadMessage" class="upload-message" :class="uploadMessageType">{{ uploadMessage }}</div>
</div>
<div v-if="loading" class="loading">搜索中...</div>
@@ -37,6 +72,12 @@
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
<button
v-if="hasLocalSongs"
class="source-btn"
:class="{ active: selectedSource === 'local' }"
@click="selectedSource = 'local'"
>本地</button>
</div>
<div class="tab-bar">
@@ -48,7 +89,7 @@
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
@@ -56,7 +97,7 @@
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
v-if="selectedSource !== 'bilibili' && selectedSource !== 'local'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
@@ -141,17 +182,19 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local';
function loadSource(): SearchSource {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
@@ -161,6 +204,12 @@ const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const uploading = ref(false);
const isDragging = ref(false);
const uploadMessage = ref('');
const uploadMessageType = ref<'info' | 'error'>('info');
const fileInput = ref<HTMLInputElement | null>(null);
const localAudioEnabled = ref(true);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
@@ -174,14 +223,16 @@ const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
// B站 / 本地上传没有专辑和歌单页签,切换时强制回到单曲。
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
if ((src === 'bilibili' || src === 'local') && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
@@ -223,10 +274,83 @@ async function doSearch() {
}
}
function isAudioFile(file: File): boolean {
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
}
async function uploadLocalFiles(fileList: File[]) {
if (!localAudioEnabled.value) {
uploadMessageType.value = 'error';
uploadMessage.value = '本地音频播放已关闭';
return;
}
const files = fileList.filter(isAudioFile);
if (files.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件';
return;
}
uploading.value = true;
uploadMessageType.value = 'info';
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = [];
const failed: string[] = [];
for (const file of files) {
try {
const res = await axios.post('/api/music/local/upload', file, {
headers: {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': encodeURIComponent(file.name),
},
maxBodyLength: Infinity,
});
if (res.data?.song) uploaded.push(res.data.song as Song);
} catch (err: any) {
failed.push(`${file.name}: ${err?.response?.data?.error || '上传失败'}`);
}
}
if (uploaded.length > 0) {
const uploadedKeys = new Set(uploaded.map((s) => `${s.platform}-${s.id}`));
allSongs.value = [
...uploaded,
...allSongs.value.filter((s) => !uploadedKeys.has(`${s.platform}-${s.id}`)),
];
selectedSource.value = 'local';
activeTab.value = 'songs';
searched.value = true;
uploadMessageType.value = failed.length ? 'error' : 'info';
uploadMessage.value = failed.length
? `已上传 ${uploaded.length} 个,失败 ${failed.length} 个:${failed[0]}`
: `已上传 ${uploaded.length} 个本地音频`;
} else {
uploadMessageType.value = 'error';
uploadMessage.value = failed[0] || '上传失败';
}
uploading.value = false;
}
function handleDrop(event: DragEvent) {
isDragging.value = false;
const files = Array.from(event.dataTransfer?.files ?? []);
uploadLocalFiles(files);
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
uploadLocalFiles(Array.from(input.files ?? []));
input.value = '';
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地';
return '网易云';
}
@@ -234,10 +358,24 @@ function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local';
return 'badge-netease';
}
async function loadLocalAudioSetting() {
try {
const res = await axios.get('/api/bot/settings');
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
if (!localAudioEnabled.value && selectedSource.value === 'local') {
selectedSource.value = 'netease';
}
} catch {
// Guests may not be allowed to read settings; backend still enforces the switch.
}
}
onMounted(() => {
loadLocalAudioSetting();
if (query.value) doSearch();
});
</script>
@@ -258,6 +396,86 @@ onMounted(() => {
margin-bottom: 24px;
}
.local-upload {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
transition: border-color var(--transition-fast), background var(--transition-fast), transform var(--transition-fast);
&.dragging {
border-color: var(--color-primary);
background: var(--color-primary-10);
transform: translateY(-1px);
}
&.uploading {
opacity: 0.8;
}
}
.upload-icon {
flex-shrink: 0;
font-size: 28px;
color: var(--color-primary);
}
.upload-copy {
flex: 1;
min-width: 0;
}
.upload-title {
font-size: 14px;
font-weight: var(--fw-semi);
color: var(--text-primary);
}
.upload-subtitle {
margin-top: 3px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.upload-btn {
flex-shrink: 0;
padding: 8px 14px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
font-size: 13px;
font-weight: var(--fw-semi);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
.file-input {
display: none;
}
.local-upload.disabled {
opacity: 0.65;
border-style: solid;
}
.upload-message {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
&.error {
color: #e74c3c;
}
}
.search-input-wrap {
display: flex;
align-items: center;
@@ -422,6 +640,11 @@ onMounted(() => {
color: var(--brand-youtube);
}
.badge-local {
background: var(--color-primary-10);
color: var(--color-primary);
}
.fav-badge {
position: absolute;
top: 8px;
+22
View File
@@ -453,6 +453,19 @@
@change="saveAutoPause"
/>
</label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">本地音频播放</div>
<div class="profile-toggle-hint">开启后允许在搜索页拖拽/选择本地音频上传并播放;关闭后会拒绝新的本地上传和本地歌曲播放请求。</div>
</div>
<input
v-model="localAudioEnabled"
type="checkbox"
class="profile-toggle-switch"
@change="saveLocalAudioEnabled"
/>
</label>
</section>
<!-- Guest Mode (admin only) -->
@@ -1037,12 +1050,14 @@ async function savePrefix() {
const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false);
const localAudioEnabled = ref(true);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ }
@@ -1060,6 +1075,13 @@ async function saveAutoPause() {
} catch { /* ignore */ }
}
async function saveLocalAudioEnabled() {
try {
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
localAudioEnabled.value = res.data.localAudioEnabled ?? localAudioEnabled.value;
} catch { /* ignore */ }
}
// --- Guest mode (admin only) ---
const GUEST_FLAGS: { token: string; label: string }[] = [
{ token: 'addToQueue', label: '添加到队列末尾' },