diff --git a/apps/music.js b/apps/music.js index 2e1142a..07abd80 100644 Binary files a/apps/music.js and b/apps/music.js differ diff --git a/lib/music/audioProcessor.js b/lib/music/audioProcessor.js new file mode 100644 index 0000000..0835b0d --- /dev/null +++ b/lib/music/audioProcessor.js @@ -0,0 +1,410 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import axios from 'axios'; +import { spawn } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class AudioProcessor { + constructor() { + this.tempDir = path.join(__dirname, '..', '..','..','..', 'temp'); + this.audioDir = path.join(this.tempDir, 'audio'); + this.ffmpegPath = this.findFFmpegPath(); + } + + /** + * 查找系统中的ffmpeg路径 + * @returns {string|null} ffmpeg路径或null + */ + findFFmpegPath() { + try { + //Start by checking if ffmpeg is included in the environment variable PATH + const pathEnv = process.env.PATH || process.env.Path; + if (pathEnv) { + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathDirs = pathEnv.split(pathSeparator); + + for (const dir of pathDirs) { + try { + const ffmpegPath = process.platform === 'win32' + ? path.join(dir, 'ffmpeg.exe') + : path.join(dir, 'ffmpeg'); + + if (fs.existsSync(ffmpegPath)) { + fs.accessSync(ffmpegPath, fs.constants.X_OK); + return ffmpegPath; + } + } catch { + + } + } + } + + //check common ffmpeg paths + const possiblePaths = [ + 'ffmpeg', + '/usr/bin/ffmpeg', + '/usr/local/bin/ffmpeg', + 'C:\\ffmpeg\\bin\\ffmpeg.exe', + 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', + 'C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe', + 'D:\\ffmpeg\\bin\\ffmpeg.exe', + 'C:\\Users\\*\\AppData\\Local\\ffmpeg\\bin\\ffmpeg.exe' + ]; + + for (const p of possiblePaths) { + //wildcard situation + if (p.includes('*')) { + const baseDir = p.split('\\*')[0]; + try { + const dirs = fs.readdirSync(baseDir); + for (const dir of dirs) { + const fullPath = path.join(baseDir, dir, 'bin', 'ffmpeg.exe'); + if (fs.existsSync(fullPath)) { + fs.accessSync(fullPath, fs.constants.X_OK); + return fullPath; + } + } + } catch { + + } + } else { + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + + } + } + } + + // check if ffmpeg can be executed directly + try { + const testProcess = spawn('ffmpeg', ['-version'], { stdio: 'ignore' }); + testProcess.on('spawn', () => { + testProcess.kill(); + }); + testProcess.on('error', () => { + logger.warn('[crystelf-music] ffmpeg 不在PATH中,可能存在问题'); + }); + return 'ffmpeg'; + } catch { + return null; + } + } catch (error) { + logger.error('[crystelf-music] 查找 ffmpeg 路径时出错:', error); + return null; + } + } + + /** + * 初始化音频处理器 + */ + async init() { + try { + // make sure the temporary directory exists + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }); + } + + if (!fs.existsSync(this.audioDir)) { + fs.mkdirSync(this.audioDir, { recursive: true }); + } + + if (this.ffmpegPath) { + } else { + logger.warn('[crystelf-music] 未找到ffmpeg,低音质转换功能可能不可用'); + } + } catch (error) { + logger.error('[crystelf-music] 音频处理器初始化失败:', error); + } + } + + /** + * 下载音频文件 + * @param {string} url 音频URL + * @param {Object} songInfo 歌曲信息 + * @param {string} groupId 群聊ID + * @returns {Promise} 下载结果 + */ + async downloadAudio(url, songInfo, groupId) { + try { + const filename = `${this.sanitizeFilename(songInfo.displayTitle)}_${songInfo.id}.${this.getFileExtension(url)}`; + const filePath = path.join(this.audioDir, filename); + + logger.info(`[crystelf-music] 开始下载音频: ${songInfo.displayTitle}`); + + // check if the file already exists + if (fs.existsSync(filePath)) { + logger.info(`[crystelf-music] 文件已存在,使用缓存: ${filename}`); + return { + success: true, + filePath, + filename, + size: fs.statSync(filePath).size, + cached: true + }; + } + + // download the file + const response = await axios({ + url, + method: 'GET', + responseType: 'stream', + timeout: 30000, + headers: { + 'User-Agent': 'crystelf-music/1.0' + } + }); + + const writer = fs.createWriteStream(filePath); + response.data.pipe(writer); + + await new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', reject); + }); + + const stats = fs.statSync(filePath); + logger.info(`[crystelf-music] 音频下载完成: ${filename} (${this.formatFileSize(stats.size)})`); + + return { + success: true, + filePath, + filename, + size: stats.size, + cached: false + }; + + } catch (error) { + logger.error(`[crystelf-music] 下载音频失败:`, error); + } + } + + /** + * 处理音频文件 + * @param {string} filePath 音频文件路径 + * @param {Object} songInfo 歌曲信息 + * @param {number} quality 音质设置 (1=低音质转语音, 2=320kbps, 3=FLAC) + * @param {string} groupId 群聊ID + * @returns {Promise} 处理结果 + */ + async processAudio(filePath, songInfo, quality, groupId) { + try { + const filename = path.basename(filePath, path.extname(filePath)); + + if (quality === 1) { + return await this.convertToVoice(filePath, filename, songInfo, groupId); + } else { + return { + success: true, + filePath, + originalPath: filePath, + type: 'audio', + quality: quality, + songInfo + }; + } + } catch (error) { + logger.error('[crystelf-music] 音频处理失败:', error); + } + } + + /** + * 将音频转换为语音格式 + * @param {string} inputPath 输入文件路径 + * @param {string} filename 文件名 + * @param {Object} songInfo 歌曲信息 + * @param {string} groupId 群聊ID + * @returns {Promise} 转换结果 + */ + async convertToVoice(inputPath, filename, songInfo, groupId) { + if (!this.ffmpegPath) { + logger.error('[crystelf-music] 未找到ffmpeg,无法进行语音转换'); + return null; + } + + const outputPath = path.join(this.audioDir, `${filename}_voice.silk`); + + try { + logger.info(`[crystelf-music] 开始转换为语音格式: ${songInfo.displayTitle}`); + const ffmpegArgs = [ + '-i', inputPath, + '-ar', '24000', // sampling rate 24khz + '-ac', '1', // mono + '-ab', '32k', // bit rate 32kbps + '-f', 'wav', // convert to wav first + '-' + ]; + + const wavPath = path.join(this.audioDir, `${filename}_temp.wav`); + + // convert to wav + await this.runFFmpeg(ffmpegArgs, wavPath); + + // convert to voice format + const finalArgs = [ + '-i', wavPath, + '-ar', '16000', // voice sampling rate + '-ac', '1', // mono + '-ab', '16k', // voice bit rate + '-f', 'wav', + outputPath + ]; + + await this.runFFmpeg(finalArgs, null); + try { + fs.unlinkSync(wavPath); + } catch {} + + const stats = fs.statSync(outputPath); + logger.info(`[crystelf-music] 语音转换完成: ${path.basename(outputPath)} (${this.formatFileSize(stats.size)})`); + + return { + success: true, + filePath: outputPath, + originalPath: inputPath, + type: 'voice', + quality: 1, + songInfo + }; + + } catch (error) { + logger.error('[crystelf-music] 语音转换失败:', error); + logger.info('[crystelf-music] 转换失败,返回原始音频文件'); + return { + success: true, + filePath: inputPath, + originalPath: inputPath, + type: 'audio', + quality: 1, + songInfo, + warning: '语音转换失败,已发送原始音频文件' + }; + } + } + + /** + * 执行ffmpeg命令 + * @param {Array} args ffmpeg参数 + * @param {string|null} outputPath 输出文件路径 + * @returns {Promise} + */ + async runFFmpeg(args, outputPath) { + return new Promise((resolve, reject) => { + const ffmpeg = spawn(this.ffmpegPath, args); + let stderr = ''; + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ffmpeg.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`ffmpeg退出代码: ${code}, 错误: ${stderr}`)); + } + }); + + ffmpeg.on('error', (error) => { + reject(new Error(`ffmpeg执行失败: ${error.message}`)); + }); + if (outputPath) { + const outputStream = fs.createWriteStream(outputPath); + ffmpeg.stdout.pipe(outputStream); + } + }); + } + + /** + * 清理临时文件 + * @param {string} filePath 文件路径 + * @param {number} maxAge 最大保留时间(毫秒) + */ + async cleanupFile(filePath, maxAge = 30 * 60 * 1000) { // default 30 minutes + try { + if (fs.existsSync(filePath)) { + const stats = fs.statSync(filePath); + const age = Date.now() - stats.mtime.getTime(); + + if (age > maxAge) { + fs.unlinkSync(filePath); + } + } + } catch (error) { + logger.warn('[crystelf-music] 清理文件失败:', error.message); + } + } + + /** + * 清理所有临时文件 + */ + async cleanupAll() { + try { + if (fs.existsSync(this.audioDir)) { + const files = fs.readdirSync(this.audioDir); + let cleaned = 0; + + for (const file of files) { + const filePath = path.join(this.audioDir, file); + await this.cleanupFile(filePath, 0); + cleaned++; + } + } + } catch (error) { + logger.error('[crystelf-music] 清理临时文件失败:', error); + } + } + + /** + * 清理文件名中的特殊字符 + * @param {string} filename 原始文件名 + * @returns {string} 清理后的文件名 + */ + sanitizeFilename(filename) { + return filename + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, '_') + .substring(0, 100); + } + + /** + * 根据URL获取文件扩展名 + * @param {string} url 文件URL + * @returns {string} 文件扩展名 + */ + getFileExtension(url) { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const ext = path.extname(pathname); + return ext ? ext.substring(1) : 'mp3'; + } catch { + return 'mp3'; + } + } + + /** + * 格式化文件大小 + * @param {number} bytes 字节数 + * @returns {string} 格式化后的大小 + */ + formatFileSize(bytes) { + if (!bytes) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + } +} + +export default AudioProcessor; \ No newline at end of file diff --git a/lib/music/musicApi.js b/lib/music/musicApi.js new file mode 100644 index 0000000..2073e21 --- /dev/null +++ b/lib/music/musicApi.js @@ -0,0 +1,252 @@ +import crypto from 'crypto'; +import axios from 'axios'; +import configControl from '../config/configControl.js'; + +class MusicApi { + constructor() { + this.config = null; + this.baseUrl = null; + this.username = null; + this.password = null; + } + + async init() { + this.config = configControl.get('music'); + this.baseUrl = this.config.url; + this.username = this.config.username; + this.password = this.config.password; + } + + /** + * 生成OpenSubsonic API认证token + * @param {string} salt 随机盐值 + * @returns {string} MD5哈希token + */ + generateToken(salt) { + return crypto.createHash('md5').update(this.password + salt).digest('hex'); + } + + /** + * 构建带认证的API URL + * @param {string} method API方法名 + * @param {Object} params 额外参数 + * @returns {Object} 包含url和参数的请求对象 + */ + buildApiRequest(method, params = {}) { + const salt = crypto.randomBytes(16).toString('hex'); + const token = this.generateToken(salt); + + const requestParams = { + u: this.username, + t: token, + s: salt, + v: '1.16.1', + c: 'crystelfmusic', + f: 'json', + ...params + }; + const url = `${this.baseUrl}/rest/${method}.view`; + return { + url, + params: requestParams + }; + } + + + /** + * 通用API请求方法 + * @param {string} method API方法名 + * @param {Object} params 请求参数 + * @returns {Promise} API响应 + */ + async request(method, params = {}) { + try { + const requestConfig = this.buildApiRequest(method, params); + const response = await axios.get(requestConfig.url, { params: requestConfig.params }); + if (response.data && response.data['subsonic-response']) { + const data = response.data['subsonic-response']; + if (data.status === 'ok') { + return data; + } else { + logger.error(`[crystelf-music] API返回错误: ${data.error?.message || '未知错误'}`); + return null; + } + } else { + logger.error('[crystelf-music] API返回格式异常'); + return null; + } + } catch (error) { + logger.error(`[crystelf-music] API请求失败 [${method}]: ${error.message}`); + return null; + } + } + + /** + * 搜索音乐 + * @param {string} query 搜索关键词(歌曲名、歌手名或专辑名) + * @param {number} count 返回结果数量 + * @returns {Promise} 搜索结果数组 + */ + async searchMusic(query, count = 20) { + if (!query || query.trim().length === 0) { + logger.error('[crystelf-music] 搜索关键词不能为空'); + return null; + } + + try { + const response = await this.request('search3', { + query: query.trim(), + songCount: count, + songOffset: 0, + artistCount: 0, + albumCount: 0 + }); + const searchResult = response.searchResult3; + const songs = searchResult?.song || []; + songs.sort((a, b) => b.score - a.score); + const topSongs = songs.slice(0, count); + logger.info(topSongs); + return this.enhanceSearchResults(topSongs, query); + } catch (error) { + logger.error('[crystelf-music] 搜索失败:', error.message); + return null; + } + } + + + /** + * 增强搜索结果并排序 + * @param {Array} songs 原始歌曲列表 + * @param {string} query 搜索关键词 + * @returns {Array} 增强后的歌曲列表 + */ + enhanceSearchResults(songs, query) { + const queryLower = query.toLowerCase(); + + return songs.map((song, index) => { + const title = song.title?.toLowerCase() || ''; + const artist = song.artist?.toLowerCase() || ''; + const album = song.album?.toLowerCase() || ''; + let score = 0; + if (title.includes(queryLower)) { + score += 100; + if (title.startsWith(queryLower)) score += 50; + } + if (artist.includes(queryLower)) { + score += 50; + if (artist.startsWith(queryLower)) score += 25; + } + if (album.includes(queryLower)) { + score += 30; + if (album.startsWith(queryLower)) score += 15; + } + if (song.duration && song.duration > 120 && song.duration < 480) { + score += 10; + } + score += (1000 - index) * 0.01; + + return { + ...song, + score, + displayTitle: song.title || '未知歌曲', + displayArtist: song.artist || '未知艺术家', + displayAlbum: song.album || '未知专辑', + duration: song.duration ? this.formatDuration(song.duration) : '未知', + format: this.getAudioFormat(song.suffix), + size: song.size ? this.formatFileSize(song.size) : '未知大小' + }; + }).sort((a, b) => b.score - a.score); + } + + /** + * 根据ID获取歌曲详细信息 + * @param {string} songId 歌曲ID + * @returns {Promise} 歌曲详细信息 + */ + async getSongById(songId) { + try { + const response = await this.request('getSong', { id: songId }); + return response.song; + } catch (error) { + logger.error('[crystelf-music] 获取歌曲信息失败:', error.message); + return null; + } + } + + /** + * 根据音质设置获取流媒体URL + * @param {string} songId 歌曲ID + * @param {number} quality 音质设置 (1=96kbps, 2=320kbps, 3=FLAC) + * @returns {string} 流媒体URL + */ + getStreamingUrl(songId, quality) { + const qualityMap = { + 1: { maxBitRate: 96, format: 'mp3' }, + 2: { maxBitRate: 320, format: 'mp3' }, + 3: { maxBitRate: 0, format: 'flac' } // 0表示无损 + }; + + const q = qualityMap[quality] || qualityMap[3]; + + const requestConfig = this.buildApiRequest('stream', { + id: songId, + maxBitRate: q.maxBitRate, + format: q.format + }); + const urlParams = new URLSearchParams(requestConfig.params).toString(); + return `${requestConfig.url}?${urlParams}`; + } + + /** + * 格式化时长 + * @param {number} seconds 秒数 + * @returns {string} 格式化后的时长 + */ + formatDuration(seconds) { + if (!seconds) return '未知'; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + } + + /** + * 格式化文件大小 + * @param {number} bytes 字节数 + * @returns {string} 格式化后的大小 + */ + formatFileSize(bytes) { + if (!bytes) return '未知'; + + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + } + + /** + * 根据文件后缀获取音频格式 + * @param {string} suffix 文件后缀 + * @returns {string} 音频格式 + */ + getAudioFormat(suffix) { + const formatMap = { + 'mp3': 'MP3', + 'flac': 'FLAC', + 'aac': 'AAC', + 'm4a': 'M4A', + 'ogg': 'OGG', + 'wav': 'WAV' + }; + + return formatMap[suffix?.toLowerCase()] || suffix?.toUpperCase() || '未知'; + } +} + +export default MusicApi; \ No newline at end of file diff --git a/lib/music/musicRenderer.js b/lib/music/musicRenderer.js new file mode 100644 index 0000000..4051c78 --- /dev/null +++ b/lib/music/musicRenderer.js @@ -0,0 +1,321 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import puppeteer from 'puppeteer'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class MusicRenderer { + constructor() { + this.tempDir = path.join(__dirname, '..', '..','..','..', 'temp'); + this.browser = null; + } + + /** + * 初始化渲染器 + */ + async init() { + try { + this.browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-accelerated-2d-canvas', + '--no-first-run', + '--no-zygote', + '--single-process', + '--disable-gpu' + ] + }); + } catch (error) { + logger.error('[crystelf-music] 浏览器初始化失败:', error); + throw error; + } + } + + /** + * 渲染音乐列表图片 + * @param {Array} songs 歌曲列表 + * @param {string} query 搜索关键词 + * @param {string} groupId 群聊ID + * @returns {Promise} 图片文件路径 + */ + async renderMusicList(songs, query, groupId) { + try { + if (!this.browser) { + await this.init(); + } + const page = await this.browser.newPage(); + await page.setViewport({ width: 800, height: Math.max(600, songs.length * 80 + 200) }); + const htmlContent = this.generateHtml(songs, query); + await page.setContent(htmlContent, { + waitUntil: 'networkidle0', + timeout: 30000 + }); + const filename = `music_list_${groupId}_${Date.now()}.png`; + const outputPath = path.join(this.tempDir, filename); + await page.screenshot({ + path: outputPath, + type: 'jpeg', + fullPage: true, + quality: 90 + }); + await page.close(); + return outputPath; + } catch (error) { + logger.error('[crystelf-music] 渲染音乐列表失败:', error); + throw new Error(`渲染失败: ${error.message}`); + } + } + + /** + * 生成HTML内容 + * @param {Array} songs 歌曲列表 + * @param {string} query 搜索关键词 + * @returns {string} HTML字符串 + */ + generateHtml(songs, query) { + const currentTime = new Date().toLocaleString('zh-CN'); + + const songItems = songs.map((song, index) => ` +
+ +
+
${this.escapeHtml(song.displayTitle)}
+ +
+ ${this.escapeHtml(song.displayArtist)} + ${this.escapeHtml(song.displayAlbum)} +
+ +
+ ${song.duration} + ${song.format} +
+
+ +
${index + 1}
+
+ `).join(''); + + return ` + + + + + +音乐搜索结果 + + + + + + +
+ +
+

音乐搜索结果

+
搜索:${this.escapeHtml(query)} | 时间:${currentTime}
+
+ +
+ ${songItems} +
+ + +
+ + + + `; + } + + /** + * HTML转义 + * @param {string} text 原始文本 + * @returns {string} 转义后的文本 + */ + escapeHtml(text) { + if (!text) return ''; + + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + return text.toString().replace(/[&<>"']/g, (m) => map[m]); + } + + /** + * 关闭浏览器 + */ + async close() { + if (this.browser) { + await this.browser.close(); + this.browser = null; + } + } +} + +export default MusicRenderer; \ No newline at end of file diff --git a/lib/music/musicSearch.js b/lib/music/musicSearch.js new file mode 100644 index 0000000..3afe019 --- /dev/null +++ b/lib/music/musicSearch.js @@ -0,0 +1,247 @@ +import configControl from '../config/configControl.js'; +import MusicApi from './musicApi.js'; +import MusicRenderer from './musicRenderer.js'; +import AudioProcessor from './audioProcessor.js'; + +class MusicSearch { + constructor() { + this.api = new MusicApi(); + this.renderer = new MusicRenderer(); + this.audioProcessor = new AudioProcessor(); + this.searchHistory = new Map(); + this.userSelections = new Map(); + } + + /** + * 初始化搜索管理器 + */ + async init() { + try { + await this.api.init(); + await this.renderer.init(); + await this.audioProcessor.init(); + } catch (error) { + logger.error('[crystelf-music] 音乐搜索管理器初始化失败:', error); + throw error; + } + } + + /** + * 处理用户搜索请求 + * @param {Object} e 事件对象 + * @param {string} keyword 搜索关键词 + * @returns {Promise} 搜索结果 + */ + async handleSearch(e, keyword) { + try { + const groupId = e.group_id; + this.clearGroupSearch(groupId); + const songs = await this.api.searchMusic(keyword, 15); + + if (!songs || songs.length === 0) { + return { + success: false, + message: '未找到相关音乐,请尝试其他关键词' + }; + } + this.searchHistory.set(String(groupId), { + songs, + query: keyword, + timestamp: Date.now(), + userId: e.user_id + }); + const imagePath = await this.renderer.renderMusicList(songs, keyword, groupId); + + return { + success: true, + songs, + imagePath, + message: `找到 ${songs.length} 首相关音乐,请选择你要听的歌曲` + }; + + } catch (error) { + logger.error('[crystelf-music] 搜索处理失败:', error); + + let errorMessage = '搜索失败,请稍后重试'; + if (error.message.includes('API')) { + errorMessage = '音乐服务器连接失败,请检查配置'; + } else if (error.message.includes('搜索')) { + errorMessage = error.message; + } + + return { + success: false, + message: errorMessage + }; + } + } + + /** + * 处理用户选择播放 + * @param {Object} e 事件对象 + * @param {number|string} selection 用户选择的序号 + * @returns {Promise} 播放结果 + */ + async handleSelection(e, selection) { + try { + const groupId = e.group_id; + const userId = e.user_id; + let index; + if (typeof selection === 'string') { + index = parseInt(selection) - 1; // the user entered 1 based and changed to 0 based + } else { + index = selection - 1; + } + + if (isNaN(index) || index < 0) { + return { + success: false, + message: '请输入有效的歌曲序号(1-20)' + }; + } + const searchResult = this.searchHistory.get(String(groupId)); + if (!searchResult) { + return { + success: false, + message: '没有找到搜索结果,请先使用 "#点歌 + 歌名" 进行搜索' + }; + } + + const { songs, query } = searchResult; + + if (index >= songs.length) { + return { + success: false, + message: `序号超出范围,当前搜索结果只有 ${songs.length} 首歌曲` + }; + } + + const selectedSong = songs[index]; + this.userSelections.set(`${String(groupId)}_${userId}`, { + song: selectedSong, + index, + timestamp: Date.now() + }); + const config = configControl.get('music'); + const quality = config?.quality || 3; + const streamUrl = this.api.getStreamingUrl(selectedSong.id, quality); + const downloadResult = await this.audioProcessor.downloadAudio(streamUrl, selectedSong, groupId); + const processResult = await this.audioProcessor.processAudio(downloadResult.filePath, selectedSong, quality, groupId); + return { + success: true, + song: selectedSong, + audioFile: processResult.filePath, + type: processResult.type, + quality: quality, + originalSong: selectedSong, + message: `正在播放: ${selectedSong.displayTitle} - ${selectedSong.displayArtist}` + }; + + } catch (error) { + logger.error('[crystelf-music] 播放处理失败:', error); + return { + success: false, + message: `播放失败: ${error.message}` + }; + } + } + + /** + * 直接播放模式 - 根据歌名搜索并播放第一首 + * @param {Object} e 事件对象 + * @param {string} songName 歌曲名称 + * @returns {Promise} 播放结果 + */ + async handleDirectPlay(e, songName) { + try { + const groupId = e.group_id; + const songs = await this.api.searchMusic(songName, 5); + + if (!songs || songs.length === 0) { + return { + success: false, + message: `未找到歌曲 "${songName}",请检查歌名是否正确` + }; + } + + const firstSong = songs[0]; + this.userSelections.set(String(groupId), { + song: firstSong, + index: 0, + timestamp: Date.now(), + directPlay: true + }); + const config = configControl.get('music'); + const quality = config?.quality || 3; + const streamUrl = this.api.getStreamingUrl(firstSong.id, quality); + const downloadResult = await this.audioProcessor.downloadAudio(streamUrl, firstSong, groupId); + const processResult = await this.audioProcessor.processAudio(downloadResult.filePath, firstSong, quality, groupId); + return { + success: true, + song: firstSong, + audioFile: processResult.filePath, + type: processResult.type, + quality: quality, + originalSong: firstSong, + message: `正在播放: ${firstSong.displayTitle} - ${firstSong.displayArtist}`, + searchQuery: songName, + foundIndex: 0 + }; + + } catch (error) { + logger.error('[crystelf-music] 直接播放失败:', error); + + return { + success: false, + message: `播放失败: ${error.message}` + }; + } + } + + /** + * 获取当前群的搜索结果 + * @param {string} groupId 群聊ID + * @returns {Object|null} 搜索结果 + */ + getGroupSearchResult(groupId) { + return this.searchHistory.get(String(groupId)) || null; + } + + /** + * 清理群聊搜索状态 + * @param {string} groupId 群聊ID + */ + clearGroupSearch(groupId) { + this.searchHistory.delete(String(groupId)); + for (const key of this.userSelections.keys()) { + if (key.startsWith(`${String(groupId)}_`)) { + this.userSelections.delete(key); + } + } + } + + /** + * 清理用户选择记录 + * @param {string} groupId 群聊ID + * @param {string} userId 用户ID + */ + clearUserSelection(groupId, userId) { + this.userSelections.delete(`${String(groupId)}_${userId}`); + } + + /** + * 关闭搜索管理器 + */ + async close() { + try { + await this.renderer.close(); + await this.audioProcessor.cleanupAll(); + this.searchHistory.clear(); + this.userSelections.clear(); + } catch (error) { + logger.error('[crystelf-music] 关闭搜索管理器时出错:', error); + } + } +} + +export default MusicSearch; \ No newline at end of file