mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat: add local audio upload playback
This commit is contained in:
1 parent
4e148302fc
commit
e12cbf8863
17 files changed
+750
-30
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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: '添加到队列末尾' },
|
||||
|
||||
Reference in new issue
Block a user