diff --git a/apps/songRequest.js b/apps/songRequest.js new file mode 100644 index 0000000..45d4322 --- /dev/null +++ b/apps/songRequest.js @@ -0,0 +1,331 @@ +import axios from "axios"; +import { formatTime } from '../utils/other.js' +import puppeteer from "../../../lib/puppeteer/puppeteer.js"; +import PickSongList from "../model/pick-song.js"; +import { NETEASE_API_CN, NETEASE_SONG_DOWNLOAD, NETEASE_TEMP_API } from "../constants/tools.js"; +import { COMMON_USER_AGENT, REDIS_YUNZAI_ISOVERSEA, REDIS_YUNZAI_SONGINFO } from "../constants/constant.js"; +import { downloadAudio } from "../utils/common.js"; +import { redisExistKey, redisGetKey, redisSetKey } from "../utils/redis-util.js"; +import { checkAndRemoveFile } from "../utils/file.js"; +import config from "../model/config.js"; + +export class songRequest extends plugin { + constructor() { + super({ + name: "R插件点歌", + dsc: "实现快捷点歌", + priority: 300, + rule: [ + { + reg: '^#?点歌|#?听[1-9][0-9]*|#?听[1-9]*$', + fnc: 'pickSong' + }, + { + reg: "^#播放(.*)", + fnc: "playSong" + }, + ] + }); + this.toolsConfig = config.getConfig("tools"); + // 加载网易云Cookie + this.neteaseCookie = this.toolsConfig.neteaseCookie + // 加载是否自建服务器 + this.useLocalNeteaseAPI = this.toolsConfig.useLocalNeteaseAPI + // 加载自建服务器API + this.neteaseCloudAPIServer = this.toolsConfig.neteaseCloudAPIServer + // 加载网易云解析最高音质 + this.neteaseCloudAudioQuality = this.toolsConfig.neteaseCloudAudioQuality + // 加载识别前缀 + this.identifyPrefix = this.toolsConfig.identifyPrefix; + // 加载是否开启网易云点歌功能 + this.useNeteaseSongRequest = this.toolsConfig.useNeteaseSongRequest + // 加载点歌列表长度 + this.songRequestMaxList = this.toolsConfig.songRequestMaxList + } + + async pickSong(e) { + // 判断功能是否开启 + if(!this.useNeteaseSongRequest) { + logger.info('当前未开启网易云点歌') + return + } + const autoSelectNeteaseApi = await this.pickApi() + // 只在群里可以使用 + let group_id = e.group.group_id + if (!group_id) return + // 初始化 + let songInfo = await redisGetKey(REDIS_YUNZAI_SONGINFO) + const saveId = songInfo.findIndex(item => item.group_id === e.group.group_id) + let musicDate = { 'group_id': group_id, data: [] } + // 获取搜索歌曲列表信息 + let searchUrl = autoSelectNeteaseApi + '/search?keywords={}&limit='+ this.songRequestMaxList //搜索API + let detailUrl = autoSelectNeteaseApi + "/song/detail?ids={}" //歌曲详情API + if (e.msg.replace(/\s+/g, "").match(/点歌(.+)/)) { + const songKeyWord = e.msg.replace(/\s+/g, "").match(/点歌(.+)/)[1] + searchUrl = searchUrl.replace("{}", songKeyWord) + await axios.get(searchUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT + }, + }).then(async res => { + if (res.data.result.songs) { + for (const info of res.data.result.songs) { + musicDate.data.push({ + 'id': info.id, + 'songName': info.name, + 'singerName': info.artists[0]?.name, + 'duration': formatTime(info.duration) + }); + } + const ids = musicDate.data.map(item => item.id).join(','); + detailUrl = detailUrl.replace("{}", ids) + await axios.get(detailUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT + }, + }).then(res => { + for (let i = 0; i < res.data.songs.length; i++) { + musicDate.data[i].cover = res.data.songs[i].al.picUrl + } + }) + if (saveId == -1) { + songInfo.push(musicDate) + } else { + songInfo[saveId] = musicDate + } + await redisSetKey(REDIS_YUNZAI_SONGINFO, songInfo) + const data = await new PickSongList(e).getData(musicDate.data) + let img = await puppeteer.screenshot("pick-song", data); + e.reply(img, true); + } else { + e.reply('暂未找到你想听的歌哦~') + } + }) + } else if (await redisGetKey(REDIS_YUNZAI_SONGINFO) != []) { + if (e.msg.match(/听(\d+)/)) { + const pickNumber = e.msg.match(/听(\d+)/)[1] - 1 + let group_id = e.group.group_id + if (!group_id) return + let songInfo = await redisGetKey(REDIS_YUNZAI_SONGINFO) + const saveId = songInfo.findIndex(item => item.group_id === e.group.group_id) + const AUTO_NETEASE_SONG_DOWNLOAD = autoSelectNeteaseApi + "/song/url/v1?id={}&level=" + this.neteaseCloudAudioQuality; + const pickSongUrl = AUTO_NETEASE_SONG_DOWNLOAD.replace("{}", songInfo[saveId].data[pickNumber].id) + const statusUrl = autoSelectNeteaseApi + '/login/status' //用户状态API + const isCkExpired = await this.checkCooike(statusUrl) + // // 请求netease数据 + this.neteasePlay(e, pickSongUrl, songInfo[saveId].data, pickNumber, isCkExpired) + } + } + + } + + // 播放策略 + async playSong(e) { + if(!this.useNeteaseSongRequest) { + logger.info('当前未开启网易云点歌') + return + } + // 只在群里可以使用 + let group_id = e.group.group_id + if (!group_id) return + const autoSelectNeteaseApi = await this.pickApi() + let songInfo = [] + // 获取搜索歌曲列表信息 + const AUTO_NETEASE_SONG_DOWNLOAD = autoSelectNeteaseApi + "/song/url/v1?id={}&level=" + this.neteaseCloudAudioQuality; + let searchUrl = autoSelectNeteaseApi + '/search?keywords={}&limit=1' //搜索API + let detailUrl = autoSelectNeteaseApi + "/song/detail?ids={}" //歌曲详情API + if (e.msg.replace(/\s+/g, "").match(/播放(.+)/)) { + const songKeyWord = e.msg.replace(/\s+/g, "").match(/播放(.+)/)[1] + searchUrl = searchUrl.replace("{}", songKeyWord) + await axios.get(searchUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT + }, + }).then(async res => { + if (res.data.result.songs) { + for (const info of res.data.result.songs) { + songInfo.push({ + 'id': info.id, + 'songName': info.name, + 'singerName': info.artists[0]?.name, + 'duration': formatTime(info.duration) + }); + } + const ids = songInfo.map(item => item.id).join(','); + detailUrl = detailUrl.replace("{}", ids) + await axios.get(detailUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT + }, + }).then(res => { + for (let i = 0; i < res.data.songs.length; i++) { + songInfo[i].cover = res.data.songs[i].al.picUrl + } + }) + const pickSongUrl = AUTO_NETEASE_SONG_DOWNLOAD.replace("{}", songInfo[0].id) + const statusUrl = autoSelectNeteaseApi + '/login/status' //用户状态API + const isCkExpired = await this.checkCooike(statusUrl) + this.neteasePlay(e, pickSongUrl, songInfo, 0, isCkExpired) + } else { + e.reply('暂未找到你想听的歌哦~') + } + }) + } + } + + + // 判断是否海外服务器 + async isOverseasServer() { + // 如果第一次使用没有值就设置 + if (!(await redisExistKey(REDIS_YUNZAI_ISOVERSEA))) { + await redisSetKey(REDIS_YUNZAI_ISOVERSEA, { + os: false, + }) + return true; + } + // 如果有就取出来 + return (await redisGetKey(REDIS_YUNZAI_ISOVERSEA)).os; + } + + // API选择 + async pickApi() { + const isOversea = await this.isOverseasServer(); + let autoSelectNeteaseApi + if (this.useLocalNeteaseAPI) { + // 使用自建 API + return autoSelectNeteaseApi = this.neteaseCloudAPIServer + } else { + // 自动选择 API + return autoSelectNeteaseApi = isOversea ? NETEASE_SONG_DOWNLOAD : NETEASE_API_CN; + } + } + + // 检测cooike活性 + + async checkCooike(statusUrl) { + let status + await axios.get(statusUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT, + "Cookie": this.neteaseCookie + }, + }).then(res => { + const userInfo = res.data.data.profile + if (userInfo) { + logger.info('ck活着,使用ck进行高音质下载') + status = true + } else { + logger.info('ck失效,将启用临时接口下载') + status = false + } + }) + return status + } + + // 网易云音乐下载策略 + neteasePlay(e, pickSongUrl, songInfo, pickNumber = 0, isCkExpired) { + axios.get(pickSongUrl, { + headers: { + "User-Agent": COMMON_USER_AGENT, + "Cookie": this.neteaseCookie + }, + }).then(async resp => { + // 国内解决方案,替换API后这里也需要修改 + + // 英转中字典匹配 + const translationDict = { + 'standard': '标准', + 'higher': '较高', + 'exhigh': '极高', + 'lossless': '无损', + 'hires': 'Hi-Res', + 'jyeffect': '高清环绕声', + 'sky': '沉浸环绕声', + 'dolby': '杜比全景声', + 'jymaster': '超清母带' + }; + + // 英转中 + function translateToChinese(word) { + return translationDict[word] || word; // 如果找不到对应翻译,返回原词 + } + + // 字节转MB + function bytesToMB(sizeInBytes) { + const sizeInMB = sizeInBytes / (1024 * 1024); // 1 MB = 1024 * 1024 bytes + return sizeInMB.toFixed(2); // 保留两位小数 + } + let url = await resp.data.data?.[0]?.url || null; + const AudioLevel = translateToChinese(resp.data.data?.[0]?.level) + const AudioSize = bytesToMB(resp.data.data?.[0]?.size) + // 获取歌曲信息 + let title = songInfo[pickNumber].songName + '-' + songInfo[pickNumber].singerName + // 一般这个情况是VIP歌曲 (如果没有url或者是国内,公用接口暂时不可用,必须自建并且ck可用状态才能进行高质量解析) + if (!isCkExpired || !this.useLocalNeteaseAPI || url == null) { + url = await this.musicTempApi(e, title, "网易云音乐"); + } else { + // 拥有ck,并且有效,直接进行解析 + let audioInfo = AudioLevel; + if (AudioLevel == '杜比全景声') { + audioInfo += '\n(杜比下载文件为MP4,编码格式为AC-4,需要设备支持才可播放)'; + } + e.reply([segment.image(songInfo[pickNumber].cover), `${this.identifyPrefix}识别:网易云音乐,${title}\n当前下载音质: ${audioInfo}\n预估大小: ${AudioSize}MB`]); + } + // 动态判断后缀名 + let musicExt = resp.data.data?.[0]?.type + // 下载音乐 + downloadAudio(url, this.getCurDownloadPath(e), title, 'follow', musicExt).then(async path => { + // 发送语音 + if (musicExt != 'mp4') { + await e.reply(segment.record(path)); + } + // 上传群文件 + await this.uploadGroupFile(e, path); + // 删除文件 + await checkAndRemoveFile(path); + }).catch(err => { + logger.error(`下载音乐失败,错误信息为: ${err}`); + }); + }); + } + + async musicTempApi(e, title, musicType) { + let musicReqApi = NETEASE_TEMP_API; + // 临时接口,title经过变换后搜索到的音乐质量提升 + const vipMusicData = await axios.get(musicReqApi.replace("{}", title.replace("-", " ")), { + headers: { + "User-Agent": COMMON_USER_AGENT, + }, + }); + const messageTitle = title + "\nR插件检测到当前为VIP音乐,正在转换..."; + // ??后的内容是适配`QQ_MUSIC_TEMP_API`、最后是汽水 + const url = vipMusicData.data?.music_url ?? vipMusicData.data?.data?.music_url ?? vipMusicData.data?.music; + const cover = vipMusicData.data?.cover ?? vipMusicData.data?.data?.cover ?? vipMusicData.data?.cover; + await e.reply([segment.image(cover), `${this.identifyPrefix}识别:${musicType},${messageTitle}`]); + return url; + } + + /** + * 获取当前发送人/群的下载路径 + * @param e Yunzai 机器人事件 + * @returns {string} + */ + getCurDownloadPath(e) { + return `${this.defaultPath}${e.group_id || e.user_id}` + } + + /** + * 上传到群文件 + * @param e 交互事件 + * @param path 上传的文件所在路径 + * @return {Promise} + */ + async uploadGroupFile(e, path) { + // 判断是否是ICQQ + if (e.bot?.sendUni) { + await e.group.fs.upload(path); + } else { + await e.group.sendFile(path); + } + } +} \ No newline at end of file diff --git a/apps/tools.js b/apps/tools.js index c557c56..3637208 100644 --- a/apps/tools.js +++ b/apps/tools.js @@ -1789,8 +1789,7 @@ export class tools extends plugin { e.reply([segment.image(coverUrl), `${this.identifyPrefix}识别:网易云音乐,${title}\n当前下载音质: ${audioInfo}\n预估大小: ${AudioSize}MB`]); } // 动态判断后缀名 - const extensionPattern = /\.([a-zA-Z0-9]+)$/; - let musicExt = url.match(extensionPattern)?.[0].replace("\.", ""); + let musicExt = resp.data.data?.[0]?.type // 下载音乐 downloadAudio(url, this.getCurDownloadPath(e), title, 'follow', musicExt).then(async path => { // 发送语音 diff --git a/config/help.yaml b/config/help.yaml index cadb3fd..46f2486 100644 --- a/config/help.yaml +++ b/config/help.yaml @@ -78,4 +78,4 @@ list: - icon: update title: "#R插件更新" - desc: "进行更新R插件" \ No newline at end of file + desc: "进行更新R插件" diff --git a/config/tools.yaml b/config/tools.yaml index f3f0c8b..aa972c8 100644 --- a/config/tools.yaml +++ b/config/tools.yaml @@ -23,6 +23,8 @@ biliDownloadMethod: 0 # 哔哩哔哩的下载方式:0默认使用原生稳定 biliResolution: 1 # 哔哩哔哩的下载画质,0为原画,1为清晰画,2为流畅画(默认为0) useLocalNeteaseAPI: false # 是否使用网易云解析自建API +useNeteaseSongRequest: false # 是否开启网易云点歌功能 +songRequestMaxList: 10 # 网易云点歌请求最大列表数 neteaseCookie: '' # 网易云ck neteaseCloudAPIServer: '' # 网易云自建服务器地址 neteaseCloudAudioQuality: exhigh # 网易云解析最高音质 默认exhigh(极高) 分类:standard => 标准,higher => 较高, exhigh=>极高, lossless=>无损, hires=>Hi-Res, jyeffect => 高清环绕声, sky => 沉浸环绕声, dolby => 杜比全景声, jymaster => 超清母带 diff --git a/config/version.yaml b/config/version.yaml index fa6b6f0..be771da 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -10,4 +10,4 @@ 输入#R更新更新插件, 输入#R版本获取插件版本, ], -} +} \ No newline at end of file diff --git a/constants/constant.js b/constants/constant.js index 911768f..7e732e9 100644 --- a/constants/constant.js +++ b/constants/constant.js @@ -80,6 +80,13 @@ export const REDIS_YUNZAI_ISOVERSEA = "Yz:rconsole:tools:oversea"; */ export const REDIS_YUNZAI_LAGRANGE = "Yz:rconsole:tools:lagrange"; +/** + * 缓存音乐搜索列表 + * @type {string} + */ +export const REDIS_YUNZAI_SONGINFO = "Yz:rconsole:tools:songinfo"; + + /** * 某些功能的解析白名单 * @type {string} diff --git a/constants/tools.js b/constants/tools.js index 5eaac19..805f3b2 100644 --- a/constants/tools.js +++ b/constants/tools.js @@ -195,7 +195,7 @@ export const NETEASE_SONG_DETAIL = "https://neteasecloudmusicapi.vercel.app" * 致谢 NeteaseCloudMusicApi:https://gitlab.com/Binaryify/neteasecloudmusicapi * @type {string} */ -export const NETEASE_API_CN = 'https://www.markingchen.ink'; +export const NETEASE_API_CN = 'https://zmusic.i9mr.com'; /** * 下载VIP的临时接口 diff --git a/guoba.support.js b/guoba.support.js index c492828..e004404 100644 --- a/guoba.support.js +++ b/guoba.support.js @@ -260,6 +260,25 @@ export function supportGuoba() { component: "Switch", required: false, }, + { + field: "tools.useNeteaseSongRequest", + label: "开启网易云点歌功能", + bottomHelpMessage: + "默认不开启,建议搭配自建网易云API使用,以获得最佳体验", + component: "Switch", + required: false, + }, + { + field: "tools.songRequestMaxList", + label: "点歌列表长度", + bottomHelpMessage: + "网易云点歌选择列表长度默认10", + component: "InputNumber", + required: false, + componentProps: { + placeholder: "填入长度", + }, + }, { field: "tools.neteaseCloudAPIServer", label: "自建网易云API地址", diff --git a/model/pick-song.js b/model/pick-song.js new file mode 100644 index 0000000..af5be42 --- /dev/null +++ b/model/pick-song.js @@ -0,0 +1,17 @@ +import Base from './base.js' + +export default class PickSongList extends Base { + constructor (e) { + super(e) + this.model = 'pick-song' + } + + /** 生成版本信息图片 */ + async getData (songData) { + return { + ...this.screenData, + saveId: 'pick-song', + songData: songData, + } + } +} diff --git a/resources/font/江城月湖体 400W.ttf b/resources/font/江城月湖体 400W.ttf new file mode 100644 index 0000000..d4f8237 Binary files /dev/null and b/resources/font/江城月湖体 400W.ttf differ diff --git a/resources/html/pick-song/pick-song.css b/resources/html/pick-song/pick-song.css new file mode 100644 index 0000000..9631a1b --- /dev/null +++ b/resources/html/pick-song/pick-song.css @@ -0,0 +1,106 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap'); + +@font-face { + font-family: 'number'; + src: url("../../font/江城月湖体\ 400W.ttf"); +} + +body, +html { + margin: 0; + padding: 0; + font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +.songList { + min-height: 50vh; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + background: #121212ef; + position: relative; + padding: 0px 40px 20px 40px; + box-sizing: border-box; +} + +.songListNav { + box-sizing: border-box; + width: 100%; + height: 100px; + margin-top: 30px; + display: flex; + justify-content: space-between; + position: relative; + z-index: 2; +} + +.navText { + font-size: 30px; + color: #fff; + margin-left: 20px; + width: 85%; +} + +.songName{ + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.singerText{ + font-size: 25px; + color: #aaa; + margin-top: 5px; +} + + +.navInfo { + display: flex; + width: 80%; +} + +.navDuration { + color: #aaa; + width: 40px; + font-size: 25px; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.navInfo img { + width: 90px; + height: 90px; + border-radius: 8px; +} + +.bgicon { + position: absolute; + top: calc(50% - 100px); + left: calc(50% - 85px); + width: 200px; + height: 200px; + z-index: 1; +} + +.bgicon img { + width: 100%; + height: 100%; + opacity: 0.4 +} + +.number { + width: 40px; + height: 100%; + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + font-size: 40px; + color: #fff; + margin-right: 25px; + margin-left: -20px; + font-family: 'number' +} \ No newline at end of file diff --git a/resources/html/pick-song/pick-song.html b/resources/html/pick-song/pick-song.html new file mode 100644 index 0000000..2108a98 --- /dev/null +++ b/resources/html/pick-song/pick-song.html @@ -0,0 +1,34 @@ + + + + + + + 搜索歌单 + + + + +
+ {{each songData info key}} +
+ + +
+ {{ /each }} +
+ +
+
+ + + \ No newline at end of file diff --git a/resources/img/icon/neteaseRank.png b/resources/img/icon/neteaseRank.png new file mode 100644 index 0000000..b1bf308 Binary files /dev/null and b/resources/img/icon/neteaseRank.png differ diff --git a/utils/other.js b/utils/other.js new file mode 100644 index 0000000..576b4d1 --- /dev/null +++ b/utils/other.js @@ -0,0 +1,11 @@ +export function formatTime(timestamp) { + const totalSeconds = Math.floor(timestamp / 1000); // 转换为秒 + const minutes = Math.floor(totalSeconds / 60); // 分钟 + const seconds = totalSeconds % 60; // 秒钟 + + // 补零格式化 + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + return `${formattedMinutes}:${formattedSeconds}`; +} \ No newline at end of file