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..fb54b87 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,41 @@ export class BotInstance extends EventEmitter { }); } + isLocalAudioEnabled(): boolean { + return this.config.localAudioEnabled !== false; + } + + /** + * Reference-aware cleanup of uploaded local audio files. Delegates to the + * local provider, which deletes a file only when it has been played AND is + * no longer referenced by ANY bot's queue — so loop replays, prev, the song + * being re-started, and the same upload queued on another bot are all safe. + * Call this AFTER the queue mutation, so released songs are unreferenced + * (and deleted) while songs that remain queued are preserved. + */ + cleanupQueuedLocalSongs(reason: string): void { + this.sweepLocalAudio(reason); + } + + private sweepLocalAudio(reason: string): void { + const provider = this.localProvider as MusicProvider & { + sweepUnreferenced?: () => string[]; + }; + if (typeof provider.sweepUnreferenced !== "function") return; + try { + const deleted = provider.sweepUnreferenced(); + if (deleted.length) { + this.logger.info({ count: deleted.length, reason }, "Cleaned up local audio files"); + } + } catch (err) { + this.logger.warn({ err, reason }, "Local audio cleanup failed"); + } + } + + 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 setupTsEvents(): void { this.tsClient.on("textMessage", (msg: TS3TextMessage) => { this.handleTextMessage(msg).catch((err) => { @@ -161,6 +199,8 @@ export class BotInstance extends EventEmitter { // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; this.player.stop(); + this.queue.clear(); + this.sweepLocalAudio("disconnected"); // 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 @@ -242,6 +282,8 @@ export class BotInstance extends EventEmitter { disconnect(): void { this._cancelIdleTimer(); this.player.stop(); + this.queue.clear(); + this.sweepLocalAudio("disconnected"); this.connected = false; if (!this.disconnectEmitted) { this.disconnectEmitted = true; @@ -482,9 +524,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 +549,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 +668,10 @@ 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.queue.clear(); this.disableFmMode(); this.queue.add({ ...song0 }); @@ -629,6 +680,10 @@ export class BotInstance extends EventEmitter { // Reset failure counter on user-initiated play this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); + // Sweep AFTER the new song is queued+resolved: the replaced songs are no + // longer referenced (and get deleted), but song0 — if it is the same local + // upload that was already playing — stays referenced and is preserved. + this.sweepLocalAudio("replaced"); if (!ok) return `Cannot play: ${song0.name}`; return `Now playing: ${song0.name} - ${song0.artist}`; } @@ -708,6 +763,7 @@ export class BotInstance extends EventEmitter { this.player.stop(); this.autoPaused = false; this.queue.clear(); + this.sweepLocalAudio("stopped"); this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on stop"); @@ -766,6 +822,7 @@ export class BotInstance extends EventEmitter { private cmdClear(): string { this.player.stop(); this.queue.clear(); + this.sweepLocalAudio("queue_cleared"); this.disableFmMode(); this.profileManager.onSongChange(null).catch((err) => { this.logger.warn({ err }, "Profile restore failed on clear"); @@ -779,6 +836,9 @@ export class BotInstance extends EventEmitter { if (isNaN(index) || index < 0) return "Usage: !remove "; const removed = this.queue.remove(index); if (!removed) return "Invalid position"; + // Sweep after the entry is gone — the file is deleted only if no other + // queue position (or bot) still references this upload. + this.sweepLocalAudio("removed_from_queue"); this.emit("stateChange"); return `Removed: ${removed.name}`; } @@ -838,6 +898,7 @@ export class BotInstance extends EventEmitter { const songs = await provider.getPlaylistSongs(playlistId); if (songs.length === 0) return "Playlist is empty or not found"; + this.player.stop(); this.queue.clear(); this.disableFmMode(); for (const song of songs) { @@ -845,6 +906,7 @@ export class BotInstance extends EventEmitter { } const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; } @@ -873,6 +935,7 @@ export class BotInstance extends EventEmitter { const songs = await provider.getAlbumSongs(albumId); if (songs.length === 0) return "Album is empty or not found"; + this.player.stop(); this.queue.clear(); this.disableFmMode(); for (const song of songs) { @@ -880,6 +943,7 @@ export class BotInstance extends EventEmitter { } const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; } @@ -902,6 +966,7 @@ export class BotInstance extends EventEmitter { if (songs.length === 0) return "No FM songs available (need to login first)"; + this.player.stop(); this.queue.clear(); for (const song of songs) { this.queue.add({ ...song, platform: provider.platform }); @@ -913,6 +978,7 @@ export class BotInstance extends EventEmitter { const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM"; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; @@ -935,6 +1001,7 @@ export class BotInstance extends EventEmitter { filtered = result.songs.slice(0, 20); } + this.player.stop(); this.queue.clear(); this.disableFmMode(); for (const song of filtered) { @@ -945,6 +1012,7 @@ export class BotInstance extends EventEmitter { const first = this.queue.play(); if (first) await this.resolveAndPlay(first); + this.sweepLocalAudio("queue_replaced"); this.emit("stateChange"); return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`; } @@ -1050,10 +1118,10 @@ export class BotInstance extends EventEmitter { async playNext(maxRetries = 3): Promise { if (this.isAdvancing || !this.connected) return false; 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 +1161,10 @@ export class BotInstance extends EventEmitter { this.emit("stateChange"); return started; } finally { + // Reference-aware sweep: a finished local song that still sits in the + // queue (sequential history, loop/repeat, or queued on another bot) is + // preserved; only uploads no longer referenced anywhere are deleted. + this.sweepLocalAudio("playback_finished"); this.isAdvancing = false; } } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index f56c55c..bc7e711 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,21 @@ 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; + // Let the local provider see which uploads are still referenced by any + // bot's queue, so it never deletes a file another queue/bot still needs. + const referenceable = this.localProvider as Partial<{ + setInUseResolver: (resolver: () => Set) => void; + }>; + referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds()); this.database = database; this.config = config; this.logger = logger; @@ -127,6 +136,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, @@ -210,6 +220,18 @@ export class BotManager extends EventEmitter { return Array.from(this.bots.values()); } + /** Local upload ids still referenced by any bot's queue. The local provider + * uses this to avoid deleting a file another queue/bot is still using. */ + getReferencedLocalSongIds(): Set { + const ids = new Set(); + for (const bot of this.bots.values()) { + for (const song of bot.getQueueManager().list()) { + if (song.platform === "local") ids.add(song.id); + } + } + return ids; + } + async startBot(id: string): Promise { const oldBot = this.bots.get(id); if (!oldBot) throw new Error(`Bot ${id} not found`); @@ -253,6 +275,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 +328,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.test.ts b/src/music/local.test.ts new file mode 100644 index 0000000..465143c --- /dev/null +++ b/src/music/local.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalMusicProvider } from "./local.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "local-audio-test-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +// Seed real files + an index.json so we can exercise the cleanup lifecycle +// without invoking the ffmpeg duration probe that uploadAudio runs. +function makeRecord(id: string, bytes = 16) { + const filePath = join(dir, `${id}.mp3`); + writeFileSync(filePath, Buffer.alloc(bytes, 1)); + return { + id, + name: id, + artist: "本地上传", + album: "本地音乐", + duration: 0, + coverUrl: "", + platform: "local" as const, + filePath, + originalName: `${id}.mp3`, + uploadedAt: "1970-01-01T00:00:00.000Z", + size: bytes, + mimeType: "audio/mpeg", + }; +} + +function seed(records: ReturnType[]) { + writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8"); +} + +describe("LocalMusicProvider cleanup lifecycle", () => { + it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => { + const a = makeRecord("a"); + const b = makeRecord("b"); + const c = makeRecord("c"); + seed([a, b, c]); + const p = new LocalMusicProvider(dir); + const refs = new Set(["a"]); // "a" still sits in a queue somewhere + p.setInUseResolver(() => refs); + + await p.getSongUrl("a"); // played, but referenced + await p.getSongUrl("b"); // played and unreferenced + // "c" was never played (e.g. uploaded but not queued) + + const deleted = p.sweepUnreferenced(); + + expect(deleted).toEqual(["b"]); + expect(existsSync(a.filePath)).toBe(true); // referenced → kept + expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted + expect(existsSync(c.filePath)).toBe(true); // never played → kept + }); + + it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => { + const a = makeRecord("a"); + seed([a]); + const p = new LocalMusicProvider(dir); + const refs = new Set(["a"]); // loop queue still references it + p.setInUseResolver(() => refs); + + await p.getSongUrl("a"); // first pass plays it + p.sweepUnreferenced(); // "playback_finished" sweep + + expect(existsSync(a.filePath)).toBe(true); + expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works + }); + + it("re-playing a queued local song does not delete it (play-song order)", async () => { + const a = makeRecord("a"); + seed([a]); + const p = new LocalMusicProvider(dir); + // Mirror the fixed endpoint order: the song is (re)added to the queue + // BEFORE the sweep runs, so it is referenced when we sweep. + const refs = new Set(["a"]); + p.setInUseResolver(() => refs); + + await p.getSongUrl("a"); // played once + p.sweepUnreferenced(); // sweep fired after the replay re-queued it + expect(existsSync(a.filePath)).toBe(true); + expect(await p.getSongUrl("a")).not.toBeNull(); + }); + + it("deletes a played file once it leaves every queue", async () => { + const a = makeRecord("a"); + seed([a]); + const p = new LocalMusicProvider(dir); + let refs = new Set(["a"]); + p.setInUseResolver(() => refs); + + await p.getSongUrl("a"); + p.sweepUnreferenced(); + expect(existsSync(a.filePath)).toBe(true); // still queued + + refs = new Set(); // queue cleared + p.sweepUnreferenced(); + expect(existsSync(a.filePath)).toBe(false); // now removed + expect(await p.getSongUrl("a")).toBeNull(); + }); + + it("never deletes anything when the reference resolver throws", async () => { + const a = makeRecord("a"); + seed([a]); + const p = new LocalMusicProvider(dir); + p.setInUseResolver(() => { + throw new Error("manager unavailable"); + }); + await p.getSongUrl("a"); + expect(p.sweepUnreferenced()).toEqual([]); + expect(existsSync(a.filePath)).toBe(true); + }); +}); + +describe("LocalMusicProvider upload validation", () => { + it("rejects a spoofed Content-Type with a non-audio extension", async () => { + const p = new LocalMusicProvider(dir); + await expect( + p.uploadAudio({ + buffer: Buffer.from("malicious"), + originalName: "evil.exe", + mimeType: "application/octet-stream", + }), + ).rejects.toThrow(); + }); + + it("rejects an unknown extension even when the mime claims audio", async () => { + const p = new LocalMusicProvider(dir); + await expect( + p.uploadAudio({ + buffer: Buffer.from("x"), + originalName: "evil.html", + mimeType: "audio/mpeg", + }), + ).rejects.toThrow(); + }); + + it("rejects an empty file", async () => { + const p = new LocalMusicProvider(dir); + await expect( + p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }), + ).rejects.toThrow(); + }); +}); + +describe("LocalMusicProvider quota", () => { + it("evicts oldest unreferenced uploads beyond maxFiles", async () => { + const a = makeRecord("a"); + const b = makeRecord("b"); + seed([b, a]); // newest-first: b newer than a + const p = new LocalMusicProvider(dir, { maxFiles: 2 }); + p.setInUseResolver(() => new Set()); + + // Upload a third valid file → over the 2-file cap → evict the oldest ("a"). + await p.uploadAudio({ + buffer: Buffer.alloc(16, 7), + originalName: "c.mp3", + mimeType: "audio/mpeg", + }); + + expect(existsSync(a.filePath)).toBe(false); // oldest evicted + expect(existsSync(b.filePath)).toBe(true); + const result = await p.search(""); + expect(result.songs.map((s) => s.id).sort()).not.toContain("a"); + }); + + it("does not evict a referenced upload even when over the cap", async () => { + const a = makeRecord("a"); + const b = makeRecord("b"); + seed([b, a]); + const p = new LocalMusicProvider(dir, { maxFiles: 1 }); + p.setInUseResolver(() => new Set(["a"])); // "a" is queued + + await p.uploadAudio({ + buffer: Buffer.alloc(16, 7), + originalName: "c.mp3", + mimeType: "audio/mpeg", + }); + + expect(existsSync(a.filePath)).toBe(true); // protected: still queued + }); + + it("never evicts the just-uploaded file, even when every older file is referenced", async () => { + const a = makeRecord("a"); + seed([a]); + const p = new LocalMusicProvider(dir, { maxFiles: 1 }); + p.setInUseResolver(() => new Set(["a"])); // the only older file is queued + + const song = await p.uploadAudio({ + buffer: Buffer.alloc(16, 7), + originalName: "c.mp3", + mimeType: "audio/mpeg", + }); + + // The returned song must actually exist and be playable — not a phantom. + expect(await p.getSongUrl(song.id)).not.toBeNull(); + }); +}); + +describe("LocalMusicProvider filename handling", () => { + it("accepts a long filename without dropping its extension", async () => { + const p = new LocalMusicProvider(dir); + const longName = "x".repeat(300) + ".mp3"; + // Must not throw the "unsupported format" error — the extension survives. + const song = await p.uploadAudio({ + buffer: Buffer.alloc(16, 1), + originalName: longName, + mimeType: "audio/mpeg", + }); + expect(song.id).toBeTruthy(); + expect(await p.getSongUrl(song.id)).not.toBeNull(); + }); +}); diff --git a/src/music/local.ts b/src/music/local.ts new file mode 100644 index 0000000..4d03088 --- /dev/null +++ b/src/music/local.ts @@ -0,0 +1,391 @@ +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", +]); + +const DEFAULT_MAX_FILES = 200; +const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB + +export interface LocalMusicProviderOptions { + /** Max number of uploaded files kept on disk (oldest unreferenced evicted). */ + maxFiles?: number; + /** Max total bytes of uploaded files kept on disk. */ + maxTotalBytes?: number; +} + +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") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .trim(); + if (!base) return "audio"; + // Cap the total length but ALWAYS preserve the extension — truncating the + // whole string would drop a trailing ".mp3" on a long filename and make the + // file fail extension validation. + const ext = path.extname(base); + const stem = ext ? base.slice(0, base.length - ext.length) : base; + const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio"; + return `${safeStem}${ext}`; +} + +function titleFromFileName(name: string): string { + return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频"; +} + +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[] = []; + private readonly maxFiles: number; + private readonly maxTotalBytes: number; + /** Ids that have been resolved for playback at least once; only these are + * eligible for reference-aware cleanup, so freshly uploaded files that are + * not yet queued/played survive in the search list. */ + private playedIds = new Set(); + /** Returns the set of local song ids still referenced by any bot's queue. + * Deletion never removes a file whose id this set contains. */ + private inUseResolver: () => Set = () => new Set(); + /** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g. + * ffmpeg on Windows still releasing a just-stopped track). */ + private retrying = new Set(); + + constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) { + this.uploadDir = uploadDir; + this.indexPath = path.join(uploadDir, "index.json"); + this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; + mkdirSync(uploadDir, { recursive: true }); + this.loadIndex(); + } + + /** Wire the resolver the BotManager uses to report which uploads are still + * queued anywhere. Must be set before any cleanup can delete files. */ + setInUseResolver(resolver: () => Set): void { + this.inUseResolver = resolver; + } + + private referencedIds(): Set | null { + try { + return this.inUseResolver() ?? new Set(); + } catch { + // Resolver failure → references unknown → refuse to delete anything. + return null; + } + } + + 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"); + const ext = path.extname(originalName).toLowerCase(); + // Validate by the (sanitised) file extension only — never trust the + // client-supplied Content-Type. This also guarantees the STORED extension + // is one of the known audio types, so a spoofed header cannot persist an + // arbitrary-extension blob on disk. + if (!AUDIO_EXTENSIONS.has(ext)) { + 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 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(); + // Never evict the file we just accepted, even if every older file is still + // queued — returning success for a file we deleted would be a phantom entry. + this.enforceQuota(id); + 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; + // A song that is actually resolved for playback becomes eligible for + // cleanup once it is no longer referenced by any queue. + this.playedIds.add(songId); + 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; + } + + /** + * Reference-aware cleanup: delete only files that have been played at least + * once AND are no longer referenced by any bot's queue. Safe to call after + * any queue mutation — a file still queued anywhere (loop replay, prev, + * the song being re-started, the same upload queued on another bot) is kept. + * Returns the ids that were deleted. + */ + sweepUnreferenced(): string[] { + const inUse = this.referencedIds(); + if (!inUse) return []; + const deleted: string[] = []; + for (let i = this.records.length - 1; i >= 0; i--) { + const r = this.records[i]; + if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue; + if (this.unlinkRecordAt(i)) { + deleted.push(r.id); + } else { + // File still locked (e.g. ffmpeg just-stopped on Windows) — keep the + // record and retry shortly; never orphan it or abort the rest. + this.scheduleRetry(r.id); + } + } + if (deleted.length) this.saveIndex(); + return deleted; + } + + /** Evict oldest, never-referenced uploads until under the file-count and + * total-byte caps. Bounds disk use from uploads that are never played. + * `protectId` is never evicted (the file just uploaded in this same call). */ + private enforceQuota(protectId?: string): void { + if (this.records.length <= this.maxFiles && + this.totalBytes() <= this.maxTotalBytes) { + return; + } + const inUse = this.referencedIds(); + if (!inUse) return; // can't safely evict without knowing references + let count = this.records.length; + let bytes = this.totalBytes(); + let changed = false; + for (let i = this.records.length - 1; + i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes); + i--) { + const r = this.records[i]; + if (inUse.has(r.id) || r.id === protectId) continue; // never evict these + const size = r.size || 0; + if (this.unlinkRecordAt(i)) { + count--; + bytes -= size; + changed = true; + } + } + if (changed) this.saveIndex(); + } + + /** + * Delete the backing file for records[index] and drop the record from memory. + * Deletes the FILE FIRST, then mutates state only on success, so a failed + * unlink leaves the record intact (file + index stay consistent) instead of + * orphaning the file. Returns true if the file is gone (deleted or already + * absent), false if it is still present (locked). Never throws; does NOT + * persist the index — callers batch saveIndex(). + */ + private unlinkRecordAt(index: number): boolean { + const r = this.records[index]; + try { + rmSync(r.filePath, { force: true }); + } catch { + // rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If + // the file genuinely vanished anyway, fall through and drop the record. + if (existsSync(r.filePath)) return false; + } + this.records.splice(index, 1); + this.playedIds.delete(r.id); + this.retrying.delete(r.id); + return true; + } + + /** Schedule a bounded, non-blocking retry to delete a briefly-locked file. + * Uses unref'd timers so it never keeps the process alive. */ + private scheduleRetry(id: string, attempt = 1): void { + if (attempt === 1 && this.retrying.has(id)) return; + this.retrying.add(id); + const MAX_ATTEMPTS = 6; + const timer = setTimeout(() => { + const index = this.records.findIndex((r) => r.id === id); + if (index < 0) { this.retrying.delete(id); return; } // already removed + const inUse = this.referencedIds(); + if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued + if (this.unlinkRecordAt(index)) { + this.saveIndex(); + } else if (attempt < MAX_ATTEMPTS) { + this.scheduleRetry(id, attempt + 1); + } else { + this.retrying.delete(id); // give up; next sweep/quota will retry + } + }, 500 * attempt); + if (typeof timer.unref === "function") timer.unref(); + } + + private totalBytes(): number { + return this.records.reduce((n, r) => n + (r.size || 0), 0); + } + + 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..68e9b59 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,10 +283,14 @@ 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" ); @@ -315,6 +333,10 @@ export function createPlayerRouter( for (const song of queueable) { queue.add({ ...song, platform: provider.platform }); } + // Sweep AFTER the queue is rebuilt: the previous queue's local uploads are + // released and deleted, but an empty/failed playlist (early return above) + // leaves the previous queue — and its files — intact. + bot.cleanupQueuedLocalSongs?.("queue_replaced"); // Use queue.play() for sequential, or pick random index for random modes const mode = queue.getMode(); @@ -356,8 +378,12 @@ 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" ); @@ -393,6 +419,8 @@ export function createPlayerRouter( for (const song of queueable) { queue.add({ ...song, platform: provider.platform }); } + // Sweep AFTER the queue is rebuilt (see play-playlist). + bot.cleanupQueuedLocalSongs?.("queue_replaced"); const mode = queue.getMode(); let first; @@ -432,13 +460,21 @@ 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(); queue.clear(); queue.add(song); queue.play(); bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); + // Sweep AFTER the new song is queued+resolved, so replaying a local song + // that was still in the queue doesn't delete the file we're about to play. + bot.cleanupQueuedLocalSongs?.("queue_replaced"); if (!ok) { res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return; @@ -460,6 +496,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 +544,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 +577,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 +609,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 }}