Merge pull request #39 from nikoyoke1/master

新增网易云 点歌&播放 功能
This commit is contained in:
Zhiyu 2024-11-05 11:45:15 +08:00 committed by GitHub
commit e5074ea4a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 531 additions and 5 deletions

331
apps/songRequest.js Normal file
View File

@ -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<void>}
*/
async uploadGroupFile(e, path) {
// 判断是否是ICQQ
if (e.bot?.sendUni) {
await e.group.fs.upload(path);
} else {
await e.group.sendFile(path);
}
}
}

View File

@ -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 => {
// 发送语音

View File

@ -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 => 超清母带

View File

@ -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}

View File

@ -195,7 +195,7 @@ export const NETEASE_SONG_DETAIL = "https://neteasecloudmusicapi.vercel.app"
* 致谢 NeteaseCloudMusicApihttps://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的临时接口

View File

@ -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地址",

17
model/pick-song.js Normal file
View File

@ -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,
}
}
}

Binary file not shown.

View File

@ -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'
}

View File

@ -0,0 +1,34 @@
<!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>
@import url('{{pluResPath}}html/pick-song/pick-song.css');
</style>
</head>
<body>
<div class="songList">
{{each songData info key}}
<div class="songListNav">
<div class="navInfo">
<div class="number">{{ key+1 }}</div>
<img src="{{ info.cover }}" alt="">
<div class="navText">
<div class="songName">{{ info.songName }}</div>
<div class="singerText">{{ info.singerName }}</div>
</div>
</div>
<div class="navDuration">{{ info.duration }}</div>
</div>
{{ /each }}
<div class="bgicon">
<img src="{{pluResPath}}img/icon/neteaseRank.png" alt="">
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

11
utils/other.js Normal file
View File

@ -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}`;
}