mirror of
https://github.com/Jerryplusy/crystelf-plugin.git
synced 2025-12-05 15:41:56 +00:00
Compare commits
No commits in common. "941c5a2cc6f313ad89a95ede397c432500b3e766" and "84c6990dc7d23c9ea813d4e1b81157520610feee" have entirely different histories.
941c5a2cc6
...
84c6990dc7
159
apps/music.js
159
apps/music.js
@ -1,159 +0,0 @@
|
|||||||
import MusicSearch from '../lib/music/musicSearch.js';
|
|
||||||
import Group from '../lib/yunzai/group.js';
|
|
||||||
import Message from '../lib/yunzai/message.js';
|
|
||||||
import YunzaiUtils from '../lib/yunzai/utils.js';
|
|
||||||
import ConfigControl from '../lib/config/configControl.js';
|
|
||||||
|
|
||||||
let musicSearch = globalThis.__CRYSTELF_MUSIC__;
|
|
||||||
|
|
||||||
if (!musicSearch) {
|
|
||||||
musicSearch = new MusicSearch();
|
|
||||||
globalThis.__CRYSTELF_MUSIC__ = musicSearch;
|
|
||||||
musicSearch.init().then(() => {
|
|
||||||
logger.info('[crystelf-music] 初始化');
|
|
||||||
}).catch(err => {
|
|
||||||
logger.error('[crystelf-music] 初始化失败: ' + err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CrystelfMusic extends plugin {
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
name: 'crystelf-music',
|
|
||||||
dsc: '音乐点歌插件',
|
|
||||||
event: 'message.group',
|
|
||||||
priority: -1000,
|
|
||||||
rule: [
|
|
||||||
{
|
|
||||||
reg: '^#?点歌\\s+(.+)$',
|
|
||||||
fnc: 'handleSearch'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
reg: '^#?听\\s+(.+)$',
|
|
||||||
fnc: 'handleDirectPlay'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
reg: '^([1-9]|1\\d|20)$',
|
|
||||||
fnc: 'handleIndexSelection'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理点歌搜索
|
|
||||||
* @param {Object} e 事件对象
|
|
||||||
*/
|
|
||||||
async handleSearch(e) {
|
|
||||||
try {
|
|
||||||
const keyword = e.msg.replace(/^#?点歌\s*/, '').trim();
|
|
||||||
if (!keyword) {
|
|
||||||
return await e.reply('请输入要点的歌名,例如:#点歌 夜曲');
|
|
||||||
}
|
|
||||||
const adapter = await YunzaiUtils.getAdapter(e);
|
|
||||||
await Message.emojiLike(e,e.message_id,60,e.group_id,adapter);
|
|
||||||
const result = await musicSearch.handleSearch(e, keyword);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await e.reply({
|
|
||||||
type: 'image',
|
|
||||||
file: `file://${result.imagePath}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await e.reply(`${result.message}`, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[crystelf-music] 处理搜索失败:', error);
|
|
||||||
await e.reply('搜索失败,请稍后重试', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理直接播放
|
|
||||||
* @param {Object} e 事件对象
|
|
||||||
*/
|
|
||||||
async handleDirectPlay(e) {
|
|
||||||
try {
|
|
||||||
const songName = e.msg.replace(/^#?听\s*/, '').trim();
|
|
||||||
if (!songName) {
|
|
||||||
return await e.reply('请输入要听的歌名,例如:#听 夜曲', true);
|
|
||||||
}
|
|
||||||
const adapter = await YunzaiUtils.getAdapter(e);
|
|
||||||
await Message.emojiLike(e,e.message_id,60,e.group_id,adapter);
|
|
||||||
const result = await musicSearch.handleDirectPlay(e, songName);
|
|
||||||
if (result.success) {
|
|
||||||
await this.sendMusicResult(e, result);
|
|
||||||
} else {
|
|
||||||
await e.reply(`${result.message}`, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[crystelf-music] 处理直接播放失败:', error);
|
|
||||||
await e.reply('播放失败,请稍后重试', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理序号选择
|
|
||||||
* @param {Object} e 事件对象
|
|
||||||
*/
|
|
||||||
async handleIndexSelection(e) {
|
|
||||||
try {
|
|
||||||
const index = parseInt(e.msg);
|
|
||||||
if (isNaN(index) || index < 1 || index > 20) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const searchResult = musicSearch.getGroupSearchResult(e.group_id);
|
|
||||||
if (!searchResult) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const adapter = await YunzaiUtils.getAdapter(e);
|
|
||||||
await Message.emojiLike(e,e.message_id,60,e.group_id,adapter);
|
|
||||||
const result = await musicSearch.handleSelection(e, index);
|
|
||||||
if (result.success) {
|
|
||||||
await this.sendMusicResult(e, result);
|
|
||||||
} else {
|
|
||||||
await e.reply(`${result.message}`, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[crystelf-music] 处理序号选择失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送音乐结果
|
|
||||||
* @param {Object} e 事件对象
|
|
||||||
* @param {Object} result 播放结果
|
|
||||||
*/
|
|
||||||
async sendMusicResult(e, result) {
|
|
||||||
try {
|
|
||||||
const { song, audioFile, type, quality, message } = result;
|
|
||||||
//await e.reply(message);
|
|
||||||
const adapter = await YunzaiUtils.getAdapter(e);
|
|
||||||
if (type === 'voice' || quality === 1) {
|
|
||||||
await Group.sendGroupRecord(e, e.group_id, `file://${audioFile}`, adapter);
|
|
||||||
} else {
|
|
||||||
const extension = await this.getFileExtension();
|
|
||||||
const filename = `${song.displayTitle} - ${song.displayArtist}.${extension}`;
|
|
||||||
await Group.sendGroupFile(e, e.group_id, `file://${audioFile}`, filename, adapter);
|
|
||||||
}
|
|
||||||
musicSearch.clearUserSelection(e.group_id, e.user_id);
|
|
||||||
logger.info(`[crystelf-music] 音乐发送成功: ${song.displayTitle}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[crystelf-music] 发送音乐结果失败:', error);
|
|
||||||
await e.reply('发送音乐失败,请稍后重试', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取文件扩展名
|
|
||||||
* @returns {string} 文件扩展名
|
|
||||||
*/
|
|
||||||
async getFileExtension() {
|
|
||||||
const musicConfig =await ConfigControl.get('music');
|
|
||||||
//if(musicConfig.quality === '3'){
|
|
||||||
//return 'flac'
|
|
||||||
//}
|
|
||||||
return 'flac'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"url": "https://api.401658.xyz",
|
|
||||||
"username": "crystelf",
|
|
||||||
"password": "1145141919810",
|
|
||||||
"?quality": "1为96kbpsAAC,2为320kbpsAAC,3为最高16-bit/44.1kHzflac",
|
|
||||||
"quality": "3"
|
|
||||||
}
|
|
||||||
@ -1,442 +0,0 @@
|
|||||||
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<Object>} 下载结果
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
let lastProgressTime = startTime;
|
|
||||||
let lastProgressPercent = 0;
|
|
||||||
|
|
||||||
// download the file
|
|
||||||
const response = await axios({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
responseType: 'stream',
|
|
||||||
timeout: 30000,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'crystelf-music/1.0'
|
|
||||||
},
|
|
||||||
onDownloadProgress: (progressEvent) => {
|
|
||||||
const percentCompleted = Math.round(
|
|
||||||
(progressEvent.loaded * 100) / (progressEvent.total || 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentTime = Date.now();
|
|
||||||
if (percentCompleted - lastProgressPercent >= 10 ||
|
|
||||||
currentTime - lastProgressTime >= 5000) {
|
|
||||||
const loadedSize = this.formatFileSize(progressEvent.loaded);
|
|
||||||
const totalSize = this.formatFileSize(progressEvent.total || 0);
|
|
||||||
const speed = this.formatFileSize(
|
|
||||||
(progressEvent.loaded * 1000) / (currentTime - startTime)
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[crystelf-music] 下载进度: ${percentCompleted}% (${loadedSize}/${totalSize}) ` +
|
|
||||||
`速度: ${speed}/s - ${songInfo.displayTitle}`
|
|
||||||
);
|
|
||||||
lastProgressPercent = percentCompleted;
|
|
||||||
lastProgressTime = currentTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
const downloadTime = (Date.now() - startTime) / 1000;
|
|
||||||
const avgSpeed = this.formatFileSize(stats.size / downloadTime);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[crystelf-music] 音频下载完成: ${filename} (${this.formatFileSize(stats.size)}) ` +
|
|
||||||
`耗时: ${downloadTime.toFixed(1)}秒 平均速度: ${avgSpeed}/s`
|
|
||||||
);
|
|
||||||
|
|
||||||
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<Object>} 处理结果
|
|
||||||
*/
|
|
||||||
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<Object>} 转换结果
|
|
||||||
*/
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
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<Object>} 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<Array>} 搜索结果数组
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
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<Object>} 歌曲详细信息
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
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<string>} 图片文件路径
|
|
||||||
*/
|
|
||||||
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) => `
|
|
||||||
<div class="card">
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<div class="title">${this.escapeHtml(song.displayTitle)}</div>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<span class="tag artist">${this.escapeHtml(song.displayArtist)}</span>
|
|
||||||
<span class="tag album">${this.escapeHtml(song.displayAlbum)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="extra">
|
|
||||||
<span>${song.duration}</span>
|
|
||||||
<span>${song.format}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rank">${index + 1}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>音乐搜索结果</title>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "SF Pro Display", "PingFang SC", "Segoe UI", sans-serif;
|
|
||||||
background: linear-gradient(135deg, #1d2b64, #d9abb8);
|
|
||||||
padding: 30px 20px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: auto;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
padding: 35px;
|
|
||||||
border-radius: 24px;
|
|
||||||
backdrop-filter: blur(30px);
|
|
||||||
-webkit-backdrop-filter: blur(30px);
|
|
||||||
box-shadow: 0 20px 45px rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .sub {
|
|
||||||
font-size: 16px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
background: rgba(255,255,255,0.25);
|
|
||||||
border-radius: 18px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 18px;
|
|
||||||
backdrop-filter: blur(15px);
|
|
||||||
-webkit-backdrop-filter: blur(15px);
|
|
||||||
border: 1px solid rgba(255,255,255,0.3);
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
|
||||||
position: relative;
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
margin-left: 20px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #007cc9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artist {
|
|
||||||
background: rgba(13,166,180,0.5);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album {
|
|
||||||
background: rgba(39,255,0,0.36);
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extra {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.85;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank {
|
|
||||||
position: absolute;
|
|
||||||
right: 15px;
|
|
||||||
top: 15px;
|
|
||||||
background: rgba(255,255,255,0.25);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.card {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.info {
|
|
||||||
margin: 15px 0 0 0;
|
|
||||||
}
|
|
||||||
.rank {
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<h1>音乐搜索结果</h1>
|
|
||||||
<div class="sub">搜索:${this.escapeHtml(query)} | 时间:${currentTime}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="list">
|
|
||||||
${songItems}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer" style="margin-top: 20px; color: #fff; text-align: center;">
|
|
||||||
你可以直接发送歌曲序号来播放音乐,如1
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
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<Object>} 搜索结果
|
|
||||||
*/
|
|
||||||
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<Object>} 播放结果
|
|
||||||
*/
|
|
||||||
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<Object>} 播放结果
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
@ -28,65 +28,5 @@ const Group = {
|
|||||||
reject_add_request: ban,
|
reject_add_request: ban,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送群语音
|
|
||||||
* @param e
|
|
||||||
* @param group_id
|
|
||||||
* @param file 本地文件:file://,网络文件:https://
|
|
||||||
* @param adapter nc/lgr
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async sendGroupRecord(e,group_id,file,adapter='nc'){
|
|
||||||
if(adapter==='nc'){
|
|
||||||
return await e.bot.sendApi('send_group_msg',{
|
|
||||||
group_id:group_id,
|
|
||||||
message: [
|
|
||||||
{
|
|
||||||
type: "record",
|
|
||||||
data: {
|
|
||||||
file : file,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
} else if(adapter === 'lgr'){
|
|
||||||
return await e.bot.sendApi('send_group_msg',{
|
|
||||||
group_id: group_id,
|
|
||||||
message:{
|
|
||||||
type: "dict",
|
|
||||||
data:{
|
|
||||||
file:file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送群文件
|
|
||||||
* @param e
|
|
||||||
* @param group_id
|
|
||||||
* @param file file://
|
|
||||||
* @param name 文件名
|
|
||||||
* @param adapter nc/lgr
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async sendGroupFile(e,group_id,file,name,adapter='nc'){
|
|
||||||
if(adapter==='nc'){
|
|
||||||
return await e.bot.sendApi('upload_group_file',{
|
|
||||||
group_id: group_id,
|
|
||||||
file: file,
|
|
||||||
name: name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if(adapter==='lgr'){
|
|
||||||
return await e.bot.sendApi('upload_group_file',{
|
|
||||||
group_id:group_id,
|
|
||||||
file:file,
|
|
||||||
name:name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
export default Group;
|
export default Group;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user