mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 13:48:02 +00:00
feat: updates
This commit is contained in:
parent
9a52681687
commit
840a5b8e9b
104 changed files with 1645 additions and 13494 deletions
|
|
@ -1,9 +1,10 @@
|
|||
import { FastifyInstance } from 'fastify'
|
||||
import proxy from '@fastify/http-proxy'
|
||||
import { isDev } from '@/desktop/main/env'
|
||||
|
||||
async function appleMusic(fastify: FastifyInstance) {
|
||||
fastify.register(proxy, {
|
||||
upstream: 'http://168.138.174.244:35530/',
|
||||
upstream: isDev ? 'http://127.0.0.1:35530/' : 'http://168.138.174.244:35530/',
|
||||
prefix: '/r3play/apple-music',
|
||||
rewritePrefix: '/apple-music',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import fs from 'fs'
|
|||
import youtube from '@/desktop/main/youtube'
|
||||
import { CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { FetchTracksResponse } from '@/shared/api/Track'
|
||||
import store from '@/desktop/main/store'
|
||||
|
||||
const getAudioFromCache = async (id: number) => {
|
||||
// get from cache
|
||||
|
|
@ -76,47 +77,51 @@ const getAudioFromYouTube = async (id: number) => {
|
|||
const track = fetchTrackResult?.songs?.[0]
|
||||
if (!track) return
|
||||
|
||||
const data = await youtube.matchTrack(track.ar[0].name, track.name)
|
||||
if (!data) return
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: 'youtube',
|
||||
id,
|
||||
url: data.url,
|
||||
br: data.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'opus',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'opus',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
try {
|
||||
const data = await youtube.matchTrack(track.ar[0].name, track.name)
|
||||
if (!data) return
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: 'youtube',
|
||||
id,
|
||||
url: data.url,
|
||||
br: data.bitRate,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'opus',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'opus',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
r3play: {
|
||||
youtube: data,
|
||||
},
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
r3play: {
|
||||
youtube: data,
|
||||
},
|
||||
},
|
||||
],
|
||||
code: 200,
|
||||
],
|
||||
code: 200,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('getAudioFromYouTube error', id, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,9 +159,11 @@ async function audio(fastify: FastifyInstance) {
|
|||
return
|
||||
}
|
||||
|
||||
const fromYoutube = getAudioFromYouTube(id)
|
||||
if (fromYoutube) {
|
||||
return fromYoutube
|
||||
if (store.get('settings.enableFindTrackOnYouTube')) {
|
||||
const fromYoutube = getAudioFromYouTube(id)
|
||||
if (fromYoutube) {
|
||||
return fromYoutube
|
||||
}
|
||||
}
|
||||
|
||||
// 是试听歌曲就把url删掉
|
||||
|
|
@ -181,11 +188,14 @@ async function audio(fastify: FastifyInstance) {
|
|||
fastify.post(
|
||||
`/${appName.toLowerCase()}/audio/:id`,
|
||||
async (
|
||||
req: FastifyRequest<{ Params: { id: string }; Querystring: { url: string } }>,
|
||||
req: FastifyRequest<{
|
||||
Params: { id: string }
|
||||
Querystring: { url: string; bitrate: number }
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url } = req.query
|
||||
const { url, bitrate } = req.query
|
||||
if (isNaN(id)) {
|
||||
return reply.status(400).send({ error: 'Invalid param id' })
|
||||
}
|
||||
|
|
@ -200,7 +210,7 @@ async function audio(fastify: FastifyInstance) {
|
|||
}
|
||||
|
||||
try {
|
||||
await cache.setAudio(await data.toBuffer(), { id, url })
|
||||
await cache.setAudio(await data.toBuffer(), { id, url, bitrate })
|
||||
reply.status(200).send('Audio cached!')
|
||||
} catch (error) {
|
||||
reply.status(500).send({ error })
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ class Cache {
|
|||
return
|
||||
}
|
||||
|
||||
getAudio(filename: string, reply: FastifyReply) {
|
||||
async getAudio(filename: string, reply: FastifyReply) {
|
||||
if (!filename) {
|
||||
return reply.status(400).send({ error: 'No filename provided' })
|
||||
}
|
||||
|
|
@ -252,6 +252,7 @@ class Cache {
|
|||
fs.unlinkSync(path)
|
||||
return reply.status(404).send({ error: 'Audio not found' })
|
||||
}
|
||||
await prisma.audio.update({ where: { id }, data: { updatedAt: new Date() } })
|
||||
reply
|
||||
.status(206)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
|
|
@ -263,7 +264,10 @@ class Cache {
|
|||
}
|
||||
}
|
||||
|
||||
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
|
||||
async setAudio(
|
||||
buffer: Buffer,
|
||||
{ id, url, bitrate }: { id: number; url: string; bitrate: number }
|
||||
) {
|
||||
const path = `${app.getPath('userData')}/audio_cache`
|
||||
|
||||
try {
|
||||
|
|
@ -273,7 +277,7 @@ class Cache {
|
|||
}
|
||||
|
||||
const meta = await musicMetadata.parseBuffer(buffer)
|
||||
const bitRate = (meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0) / 1000
|
||||
const bitRate = ~~((meta.format.bitrate || bitrate || 0) / 1000)
|
||||
const format =
|
||||
{
|
||||
'MPEG 1 Layer 3': 'mp3',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Thumbar } from './windowsTaskbar'
|
|||
import fastFolderSize from 'fast-folder-size'
|
||||
import path from 'path'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import prisma from './prisma'
|
||||
|
||||
const on = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
|
|
@ -203,7 +204,7 @@ function initOtherIpcMain() {
|
|||
* 退出登陆
|
||||
*/
|
||||
handle(IpcChannels.Logout, async () => {
|
||||
// db.truncate(Tables.AccountData)
|
||||
await prisma.accountData.deleteMany({})
|
||||
return true
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +1,218 @@
|
|||
import log from './log'
|
||||
import youtube, { Scraper, Video } from '@yimura/scraper'
|
||||
import ytdl from 'ytdl-core'
|
||||
import axios, { AxiosProxyConfig } from 'axios'
|
||||
import store from './store'
|
||||
import httpProxyAgent from 'http-proxy-agent'
|
||||
|
||||
class YoutubeDownloader {
|
||||
yt: Scraper
|
||||
|
||||
constructor() {
|
||||
// @ts-ignore
|
||||
this.yt = new youtube.default()
|
||||
//
|
||||
}
|
||||
|
||||
async search(keyword: string) {
|
||||
const result = await this.yt.search(keyword)
|
||||
return result?.videos
|
||||
}
|
||||
async search(keyword: string): Promise<
|
||||
{
|
||||
duration: number
|
||||
id: string
|
||||
title: string
|
||||
}[]
|
||||
> {
|
||||
let proxy: AxiosProxyConfig | false = false
|
||||
if (store.get('settings.httpProxyForYouTube')) {
|
||||
const host = store.get('settings.httpProxyForYouTube.host') as string | undefined
|
||||
const port = store.get('settings.httpProxyForYouTube.port') as number | undefined
|
||||
const auth = store.get('settings.httpProxyForYouTube.auth') as any | undefined
|
||||
const protocol = store.get('settings.httpProxyForYouTube.protocol') as string | undefined
|
||||
if (host && port) {
|
||||
proxy = { host, port, auth, protocol }
|
||||
}
|
||||
}
|
||||
// proxy = { host: '127.0.0.1', port: 8888, protocol: 'http' }
|
||||
const webPage = await axios.get(`https://www.youtube.com/results`, {
|
||||
params: {
|
||||
search_query: keyword,
|
||||
sp: 'EgIQAQ==',
|
||||
},
|
||||
headers: { 'Accept-Language': 'en-US' },
|
||||
timeout: 5000,
|
||||
proxy,
|
||||
})
|
||||
|
||||
async matchTrack(artist: string, trackName: string) {
|
||||
console.time('[youtube] search')
|
||||
const videos = await this.search(`${artist} ${trackName} lyric audio`)
|
||||
console.timeEnd('[youtube] search')
|
||||
let video: Video | null = null
|
||||
|
||||
// 找官方频道最匹配的
|
||||
// videos.forEach(v => {
|
||||
// if (video) return
|
||||
// const channelName = v.channel.name.toLowerCase()
|
||||
// if (channelName !== artist.toLowerCase()) return
|
||||
// const title = v.title.toLowerCase()
|
||||
// if (!title.includes(trackName.toLowerCase())) return
|
||||
// if (!title.includes('audio') && !title.includes('lyric')) return
|
||||
// video = v
|
||||
// })
|
||||
|
||||
// TODO:找时长误差不超过2秒的
|
||||
|
||||
// 最后方案选搜索的第一个
|
||||
if (!video) {
|
||||
video = videos[0]
|
||||
if (webPage.status !== 200) {
|
||||
return []
|
||||
}
|
||||
|
||||
console.time('[youtube] getInfo')
|
||||
const info = await ytdl.getInfo('http://www.youtube.com/watch?v=' + video.id)
|
||||
console.timeEnd('[youtube] getInfo')
|
||||
let url = ''
|
||||
let bitRate = 0
|
||||
info.formats.forEach(video => {
|
||||
if (
|
||||
video.mimeType === `audio/webm; codecs="opus"` &&
|
||||
video.bitrate &&
|
||||
video.bitrate > bitRate
|
||||
) {
|
||||
url = video.url
|
||||
bitRate = video.bitrate
|
||||
// @credit https://www.npmjs.com/package/@yimura/scraper
|
||||
function _parseData(data) {
|
||||
const results = {
|
||||
channels: [],
|
||||
playlists: [],
|
||||
streams: [],
|
||||
videos: [],
|
||||
}
|
||||
|
||||
const isVideo = item => item.videoRenderer && item.videoRenderer.lengthText
|
||||
const getVideoData = item => {
|
||||
const vRender = item.videoRenderer
|
||||
const compress = key => {
|
||||
return (key && key['runs'] ? key['runs'].map(v => v.text) : []).join('')
|
||||
}
|
||||
const parseDuration = vRender => {
|
||||
if (!vRender.lengthText?.simpleText) return 0
|
||||
|
||||
const nums = vRender.lengthText.simpleText.split(':')
|
||||
let time = nums.reduce((a, t) => 60 * a + +t) * 1e3
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
return {
|
||||
duration: parseDuration(vRender),
|
||||
id: vRender.videoId,
|
||||
title: compress(vRender.title),
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data) {
|
||||
if (isVideo(item)) results.videos.push(getVideoData(item))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function _extractData(json) {
|
||||
json = json.contents.twoColumnSearchResultsRenderer.primaryContents
|
||||
|
||||
let contents = []
|
||||
|
||||
if (json.sectionListRenderer) {
|
||||
contents = json.sectionListRenderer.contents
|
||||
.filter(item =>
|
||||
item?.itemSectionRenderer?.contents.filter(
|
||||
x => x.videoRenderer || x.playlistRenderer || x.channelRenderer
|
||||
)
|
||||
)
|
||||
.shift().itemSectionRenderer.contents
|
||||
}
|
||||
|
||||
if (json.richGridRenderer) {
|
||||
contents = json.richGridRenderer.contents
|
||||
.filter(item => item.richItemRenderer && item.richItemRenderer.content)
|
||||
.map(item => item.richItemRenderer.content)
|
||||
}
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
function _getSearchData(webPage: string) {
|
||||
const startString = 'var ytInitialData = '
|
||||
const start = webPage.indexOf(startString)
|
||||
const end = webPage.indexOf(';</script>', start)
|
||||
|
||||
const data = webPage.substring(start + startString.length, end)
|
||||
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'Failed to parse YouTube search data. YouTube might have updated their site or no results returned.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedJson = _getSearchData(webPage.data)
|
||||
|
||||
const extracted = _extractData(parsedJson)
|
||||
const parsed = _parseData(extracted)
|
||||
|
||||
return parsed?.videos
|
||||
}
|
||||
|
||||
async matchTrack(
|
||||
artist: string,
|
||||
trackName: string
|
||||
): Promise<{
|
||||
url: string
|
||||
bitRate: number
|
||||
title: string
|
||||
videoId: string
|
||||
duration: string
|
||||
channel: string
|
||||
}> {
|
||||
const match = async () => {
|
||||
console.time('[youtube] search')
|
||||
const videos = await this.search(`${artist} ${trackName} audio`)
|
||||
console.timeEnd('[youtube] search')
|
||||
let video: {
|
||||
duration: number
|
||||
id: string
|
||||
title: string
|
||||
} | null = null
|
||||
|
||||
// 找官方频道最匹配的
|
||||
// videos.forEach(v => {
|
||||
// if (video) return
|
||||
// const channelName = v.channel.name.toLowerCase()
|
||||
// if (channelName !== artist.toLowerCase()) return
|
||||
// const title = v.title.toLowerCase()
|
||||
// if (!title.includes(trackName.toLowerCase())) return
|
||||
// if (!title.includes('audio') && !title.includes('lyric')) return
|
||||
// video = v
|
||||
// })
|
||||
|
||||
// TODO:找时长误差不超过2秒的
|
||||
|
||||
// 最后方案选搜索的第一个
|
||||
if (!video) {
|
||||
video = videos[0]
|
||||
}
|
||||
if (!video) return null
|
||||
|
||||
console.time('[youtube] getInfo')
|
||||
const proxy = 'http://127.0.0.1:8888'
|
||||
const agent = httpProxyAgent(proxy)
|
||||
const info = await ytdl.getInfo(video.id, {
|
||||
// requestOptions: { agent },
|
||||
})
|
||||
console.timeEnd('[youtube] getInfo')
|
||||
if (!info) return null
|
||||
let url = ''
|
||||
let bitRate = 0
|
||||
info.formats.forEach(video => {
|
||||
if (
|
||||
video.mimeType === `audio/webm; codecs="opus"` &&
|
||||
video.bitrate &&
|
||||
video.bitrate > bitRate
|
||||
) {
|
||||
url = video.url
|
||||
bitRate = video.bitrate
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
url,
|
||||
bitRate,
|
||||
title: info.videoDetails.title,
|
||||
videoId: info.videoDetails.videoId,
|
||||
duration: info.videoDetails.lengthSeconds,
|
||||
channel: info.videoDetails.ownerChannelName,
|
||||
}
|
||||
log.info(`[youtube] matched `, data)
|
||||
return data
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
setTimeout(() => reject('youtube match timeout'), 10000)
|
||||
try {
|
||||
const result = await match()
|
||||
if (result) resolve(result)
|
||||
} catch (e) {
|
||||
log.error(`[youtube] matchTrack error`, e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
url,
|
||||
bitRate,
|
||||
title: info.videoDetails.title,
|
||||
videoId: info.videoDetails.videoId,
|
||||
duration: info.videoDetails.lengthSeconds,
|
||||
channel: info.videoDetails.ownerChannelName,
|
||||
}
|
||||
log.info(`[youtube] matched `, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
return axios.get('https://www.youtube.com', { timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue