From e12cbf8863dc544c0348599582e77b199ea323f6 Mon Sep 17 00:00:00 2001 From: Fa1nttt <2185939211@qq.com> Date: Tue, 30 Jun 2026 14:08:42 +0800 Subject: [PATCH] feat: add local audio upload playback --- README.md | 4 +- src/audio/queue.ts | 2 +- src/bot/instance.ts | 82 ++++++++++- src/bot/manager.ts | 8 +- src/data/config.ts | 3 + src/data/database.ts | 2 +- src/index.ts | 7 +- src/music/local.ts | 252 ++++++++++++++++++++++++++++++++ src/music/provider.ts | 8 +- src/web/api/bot.ts | 6 +- src/web/api/music.ts | 77 +++++++++- src/web/api/player.ts | 56 ++++++- src/web/server.ts | 3 +- web/src/components/SongCard.vue | 9 +- web/src/stores/player.ts | 2 +- web/src/views/Search.vue | 237 +++++++++++++++++++++++++++++- web/src/views/Settings.vue | 22 +++ 17 files changed, 750 insertions(+), 30 deletions(-) create mode 100644 src/music/local.ts diff --git a/README.md b/README.md index e56dcea..5bd874d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 +- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 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/` 打开时锁定到单台机器人,`?bot=` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。 - **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。 - **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。 diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 3c47faf..617e7d9 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -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 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 05348ab..2912b61 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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; + }; + 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, reason: string): void { + const seen = new Set(); + 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 "; 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 { 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; } } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index f56c55c..4144752 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -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, diff --git a/src/data/config.ts b/src/data/config.ts index bc0b77d..1668fe2 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -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: { diff --git a/src/data/database.ts b/src/data/database.ts index cc7f3d8..2140eb3 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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; } diff --git a/src/index.ts b/src/index.ts index 77b42d9..fbb3871 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/music/local.ts b/src/music/local.ts new file mode 100644 index 0000000..64fd069 --- /dev/null +++ b/src/music/local.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const record = this.records.find((r) => r.id === songId); + return record && existsSync(record.filePath) ? this.toSong(record) : null; + } + + async deleteSong(songId: string): Promise { + 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 { + return []; + } + + async getRecommendPlaylists(): Promise { + return []; + } + + async getAlbumSongs(_albumId: string): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + 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 { + return { loggedIn: true, nickname: "本地音乐" }; + } + + async getPlaylistDetail(_playlistId: string): Promise { + return null; + } +} diff --git a/src/music/provider.ts b/src/music/provider.ts index 6317bbc..4b4b4cc 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -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; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index fa1e0ce..f3ff70f 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -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, }); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index d4446e7..40b84c9 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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; + }; + 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", }); }); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 61831a7..609b189 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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" ); diff --git a/src/web/server.ts b/src/web/server.ts index 5b98d49..8f3ef89 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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, diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 0746974..59232e1 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -7,8 +7,8 @@ {{ song.name }} {{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }} + :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' ? '本地' : '网易云' }}
{{ song.artist }}
@@ -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); diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 2a7004a..4dfb979 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -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'; diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index e13cade..a34408d 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -16,6 +16,41 @@ autofocus /> + +
+ +
+
拖拽本地音频到这里上传
+
支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列
+
+ + +
+
+ +
+
本地音频播放已关闭
+
管理员可在「设置 → 行为设置 → 本地音频播放」中开启。
+
+
+
{{ uploadMessage }}
搜索中...
@@ -37,6 +72,12 @@ :class="{ active: selectedSource === 'bilibili' }" @click="selectedSource = 'bilibili'" >B站 +
@@ -48,7 +89,7 @@ 单曲{{ filteredSongs.length }}