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
+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,
|
||||
|
||||
Reference in new issue
Block a user