mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 21:28:06 +00:00
220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
import log from './log'
|
|
import ytdl from 'ytdl-core'
|
|
import axios, { AxiosProxyConfig } from 'axios'
|
|
import store from './store'
|
|
import httpProxyAgent from 'http-proxy-agent'
|
|
|
|
class YoutubeDownloader {
|
|
constructor() {
|
|
//
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
if (webPage.status !== 200) {
|
|
return []
|
|
}
|
|
|
|
// @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)
|
|
}
|
|
})
|
|
}
|
|
|
|
async testConnection() {
|
|
return axios.get('https://www.youtube.com', { timeout: 5000 })
|
|
}
|
|
}
|
|
|
|
const youtubeDownloader = new YoutubeDownloader()
|
|
export default youtubeDownloader
|