nodelink / src /sources /youtube.js
flameface's picture
Upload 25 files
b58c6cb verified
import { PassThrough } from 'node:stream'
import config from '../../config.js'
import constants from '../../constants.js'
import { debugLog, makeRequest, encodeTrack, randomLetters, loadHLSPlaylist } from '../utils.js'
const ytContext = {
...(config.options.bypassAgeRestriction ? {
thirdParty: {
embedUrl: 'https://www.youtube.com'
},
} : {}),
client: {
...(!config.options.bypassAgeRestriction ? {
userAgent: 'com.google.android.youtube/19.13.34 (Linux; U; Android 14 gzip)',
clientName: 'ANDROID',
clientVersion: '19.13.34',
} : {
clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
clientVersion: '2.0',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0'
}),
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
}
}
const sourceInfo = {
innertubeInterval: null,
signatureTimestamp: null,
functions: []
}
function _getBaseHostRequest(type) {
if (ytContext.client.clientName.startsWith('ANDROID'))
return 'youtubei.googleapis.com'
return `${type === 'ytmusic' ? 'music' : 'www'}.youtube.com`
}
function _getBaseHost(type) {
return `${type === 'ytmusic' ? 'music' : 'www'}.youtube.com`
}
function _switchClient(newClient) {
if (newClient === 'ANDROID') {
ytContext.client.clientName = 'ANDROID'
ytContext.client.clientVersion = '19.04.33'
ytContext.client.userAgent = 'com.google.android.youtube/19.04.33 (Linux; U; Android 14 gzip)'
} else if (newClient === 'ANDROID_MUSIC') {
ytContext.client.clientName = 'ANDROID_MUSIC'
ytContext.client.clientVersion = '6.37.50'
ytContext.client.userAgent = 'com.google.android.apps.youtube.music/6.37.50 (Linux; U; Android 14 gzip)'
}
}
function _getSourceName(type) {
return type === 'ytmusic' ? 'YouTube Music' : 'YouTube'
}
async function _init() {
debugLog('youtube', 5, { type: 1, message: 'Fetching deciphering functions...' })
const { body: data } = await makeRequest('https://www.youtube.com/embed', { method: 'GET' }).catch((err) => {
debugLog('youtube', 5, { type: 2, message: `Failed to access YouTube website: ${err.message}` })
})
const { body: player } = await makeRequest(`https://www.youtube.com${/(?<=jsUrl":")[^"]+/.exec(data)[0]}`, { method: 'GET' }).catch((err) => {
debugLog('youtube', 5, { type: 2, message: `Failed to fetch player.js: ${err.message}` })
})
sourceInfo.signatureTimestamp = /(?<=signatureTimestamp:)[0-9]+/.exec(player)[0]
let functionName = player.match(/a.set\("alr","yes"\);c&&\(c=(.*?)\(/)[1]
const decipherFunctionName = functionName
const sigFunction = player.match(new RegExp(`${functionName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')}=function\\(a\\){(.*)\\)};`, 'g'))[0]
functionName = player.match(/a=a\.split\(""\);(.*?)\./)[1]
const sigWrapper = player.match(new RegExp(`var ${functionName}={(.*?)};`, 's'))[1]
sourceInfo.functions[0] = `const ${functionName}={${sigWrapper}};const ${sigFunction}${decipherFunctionName}(sig);`
functionName = player.match(/&&\(b=a\.get\("n"\)\)&&\(b=(.*?)\(/)[1]
if (functionName && functionName.includes('['))
functionName = player.match(new RegExp(`${functionName.match(/([^[]*)\[/)[1]}=\\[(.*?)]`))[1]
const ncodeFunction = player.match(new RegExp(`${functionName}=function(.*?)};`, 's'))[1]
sourceInfo.functions[1] = `const ${functionName} = function${ncodeFunction}};${functionName}(ncode)`
debugLog('youtube', 5, { type: 1, message: 'Successfully fetched deciphering functions.' })
}
async function init() {
debugLog('youtube', 5, { type: 1, message: 'Unrecommended option "bypass age-restricted" is enabled.' })
await _init()
sourceInfo.innertubeInterval = setInterval(async () => _init(), 3600000)
}
function free() {
clearInterval(sourceInfo.innertubeInterval)
sourceInfo.innertubeInterval = null
sourceInfo.signatureTimestamp = null
sourceInfo.functions = []
}
function checkURLType(url, type) {
if (type === 'ytmusic') {
const videoRegex = /^https?:\/\/music\.youtube\.com\/watch\?v=[\w-]+/
const playlistRegex = /^https?:\/\/music\.youtube\.com\/playlist\?list=[\w-]+/
const selectedVideoRegex = /^https?:\/\/music\.youtube\.com\/watch\?v=[\w-]+&list=[\w-]+/
if (selectedVideoRegex.test(url) || playlistRegex.test(url)) return constants.YouTube.playlist
else if (videoRegex.test(url)) return constants.YouTube.video
else return -1
} else {
const videoRegex = /^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=[\w-]+)|youtu\.be\/[\w-]+)/
const playlistRegex = /^https?:\/\/(?:www\.)?youtube\.com\/playlist\?list=[\w-]+/
const selectedVideoRegex = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+&list=[\w-]+/
const shortsRegex = /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/
if (selectedVideoRegex.test(url) || playlistRegex.test(url)) return constants.YouTube.playlist
else if (shortsRegex.test(url)) return constants.YouTube.shorts
else if (videoRegex.test(url)) return constants.YouTube.video
else return -1
}
}
async function search(query, type, shouldLog) {
if (shouldLog) debugLog('search', 4, { type: 1, sourceName: _getSourceName(type), query })
if (!config.options.bypassAgeRestriction)
_switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
const { body: search } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/search`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
query,
params: type === 'ytmusic' ? 'EgWKAQIIAWoQEAMQBBAJEAoQBRAREBAQFQ%3D%3D' : 'EgIQAQ%3D%3D'
},
method: 'POST',
disableBodyCompression: true
})
if (typeof search !== 'object') {
debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: 'Failed to load results.' })
return {
loadType: 'error',
data: {
message: 'Failed to load results.',
severity: 'common',
cause: 'Unknown'
}
}
}
if (search.error) {
debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: search.error.message })
return {
loadType: 'error',
data: {
message: search.error.message,
severity: 'fault',
cause: 'Unknown'
}
}
}
const tracks = []
let videos = null
if (config.options.bypassAgeRestriction) videos = type == 'ytmusic' ? search.contents.sectionListRenderer.contents[0].itemSectionRenderer.contents : search.contents.sectionListRenderer.contents[search.contents.sectionListRenderer.contents.length - 1].itemSectionRenderer.contents
else videos = type == 'ytmusic' ? search.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.musicSplitViewRenderer.mainContent.sectionListRenderer.contents[0].musicShelfRenderer.contents : search.contents.sectionListRenderer.contents[search.contents.sectionListRenderer.contents.length - 1].itemSectionRenderer.contents
if (videos.length > config.options.maxSearchResults)
videos = videos.slice(0, config.options.maxSearchResults)
videos.forEach((video) => {
video = video.compactVideoRenderer || video.musicTwoColumnItemRenderer
if (video) {
const identifier = type === 'ytmusic' ? video.navigationEndpoint.watchEndpoint.videoId : video.videoId
const length = type === 'ytmusic' && !config.options.bypassAgeRestriction ? video.subtitle.runs[2].text : video.lengthText?.runs[0]?.text
const thumbnails = type === 'ytmusic' && !config.options.bypassAgeRestriction ? video.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails : video.thumbnail.thumbnails
const track = {
identifier,
isSeekable: true,
author: video.longBylineText ? video.longBylineText.runs[0].text : video.subtitle.runs[0].text,
length: length ? (parseInt(length.split(':')[0]) * 60 + parseInt(length.split(':')[1])) * 1000 : 0,
isStream: !length,
position: 0,
title: video.title.runs[0].text,
uri: `https://${_getBaseHost(type)}/watch?v=${identifier}`,
artworkUrl: thumbnails[thumbnails.length - 1].url.split('?')[0],
isrc: null,
sourceName: type
}
tracks.push({
encoded: encodeTrack(track),
info: track,
pluginInfo: {}
})
}
})
if (tracks.length === 0) {
debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
if (shouldLog)
debugLog('search', 4, { type: 2, sourceName: _getSourceName(type), tracksLen: tracks.length, query })
return {
loadType: 'search',
data: tracks
}
}
async function loadFrom(query, type) {
if (!config.options.bypassAgeRestriction)
_switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
switch (checkURLType(query, type)) {
case constants.YouTube.video: {
debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: _getSourceName(type), query })
const identifier = (/v=([^&]+)/.exec(query) || /youtu\.be\/([^?]+)/.exec(query))[1]
const { body: video } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
videoId: identifier,
contentCheckOk: true,
racyCheckOk: true,
params: 'CgIQBg'
},
method: 'POST'
})
if (video.playabilityStatus.status !== 'OK') {
const errorMessage = video.playabilityStatus.reason || video.playabilityStatus.messages[0]
debugLog('loadtracks', 4, { type: 3, loadType: 'track', sourceName: _getSourceName(type), query, message: errorMessage })
return {
loadType: 'error',
data: {
message: errorMessage,
severity: 'common',
cause: 'Unknown'
}
}
}
const track = {
identifier: video.videoDetails.videoId,
isSeekable: true,
author: video.videoDetails.author,
length: parseInt(video.videoDetails.lengthSeconds) * 1000,
isStream: video.videoDetails.isLiveContent,
position: 0,
title: video.videoDetails.title,
uri: `https://${_getBaseHost(type)}/watch?v=${video.videoDetails.videoId}`,
artworkUrl: video.videoDetails.thumbnail.thumbnails[video.videoDetails.thumbnail.thumbnails.length - 1].url,
isrc: null,
sourceName: type
}
debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: _getSourceName(type), track, query })
return {
loadType: 'track',
data: {
encoded: encodeTrack(track),
info: track,
pluginInfo: {}
}
}
}
case constants.YouTube.playlist: {
debugLog('loadtracks', 4, { type: 1, loadType: 'playlist', sourceName: _getSourceName(type), query })
let identifier = /v=([^&]+)/.exec(query)
if (identifier) identifier = identifier[1]
const { body: playlist } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/next?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
playlistId: /(?<=list=)[\w-]+/.exec(query)[0],
contentCheckOk: true,
racyCheckOk: true,
params: 'CgIQBg'
},
method: 'POST'
})
let contentsRoot = null
if (config.options.bypassAgeRestriction) contentsRoot = playlist.contents.singleColumnWatchNextResults.playlist
else contentsRoot = type === 'ytmusic' ? playlist.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content.musicQueueRenderer : playlist.contents.singleColumnWatchNextResults
if (!(type === 'ytmusic' && !config.options.bypassAgeRestriction ? contentsRoot.content : contentsRoot)) {
debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
const tracks = []
let selectedTrack = 0
let playlistContent = null
if (config.options.bypassAgeRestriction) playlistContent = contentsRoot.playlist.contents
else playlistContent = type === 'ytmusic' ? contentsRoot.content.playlistPanelRenderer.contents : contentsRoot.playlist?.playlist?.contents
if (!playlistContent) {
debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
if (playlistContent.length > config.options.maxAlbumPlaylistLength)
playlistContent = playlistContent.slice(0, config.options.maxAlbumPlaylistLength)
playlistContent.forEach((video, i) => {
video = video.playlistPanelVideoRenderer || video.gridVideoRenderer
if (video) {
const track = {
identifier: video.videoId,
isSeekable: true,
author: video.shortBylineText.runs ? video.shortBylineText.runs[0].text : 'Unknown author',
length: video.lengthText ? (parseInt(video.lengthText.runs[0].text.split(':')[0]) * 60 + parseInt(video.lengthText.runs[0].text.split(':')[1])) * 1000 : 0,
isStream: false,
position: 0,
title: video.title.runs[0].text,
uri: `https://${_getBaseHost(type)}/watch?v=${video.videoId}`,
artworkUrl: video.thumbnail.thumbnails[video.thumbnail.thumbnails.length - 1].url,
isrc: null,
sourceName: 'youtube'
}
tracks.push({
encoded: encodeTrack(track),
info: track,
pluginInfo: {}
})
if (identifier && track.identifier === identifier)
selectedTrack = i
}
})
if (tracks.length === 0) {
debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
let playlistName = null
if (config.options.bypassAgeRestriction) playlistName = contentsRoot.playlist.title
else playlistName = type === 'ytmusic' ? contentsRoot.header.musicQueueHeaderRenderer.subtitle.runs[0].text : contentsRoot.playlist.playlist.title
debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: _getSourceName(type), playlistName: playlistName })
return {
loadType: 'playlist',
data: {
info: {
name: playlistName,
selectedTrack: selectedTrack
},
pluginInfo: {},
tracks
}
}
}
case constants.YouTube.shorts: {
debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: 'YouTube Shorts', query })
const { body: short } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
videoId: /shorts\/([a-zA-Z0-9_-]+)/.exec(query)[1],
contentCheckOk: true,
racyCheckOk: true,
params: 'CgIQBg'
},
method: 'POST'
})
if (short.playabilityStatus.status !== 'OK') {
const errorMessage = short.playabilityStatus.reason || short.playabilityStatus.messages[0]
debugLog('loadtracks', 4, { type: 3, loadType: 'track', sourceName: 'YouTube Shorts', query, message: errorMessage })
return {
loadType: 'error',
data: { message: errorMessage,
severity: 'common',
cause: 'Unknown'
}
}
}
const track = {
identifier: short.videoDetails.videoId,
isSeekable: true,
author: short.videoDetails.author,
length: parseInt(short.videoDetails.lengthSeconds) * 1000,
isStream: false,
position: 0,
title: short.videoDetails.title,
uri: `https://${_getBaseHost(type)}/watch?v=${short.videoDetails.videoId}`,
artworkUrl: short.videoDetails.thumbnail.thumbnails[short.videoDetails.thumbnail.thumbnails.length - 1].url,
isrc: null,
sourceName: 'youtube'
}
debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'YouTube Shorts', track, query })
return {
loadType: 'short',
data: {
encoded: encodeTrack(track),
info: track,
pluginInfo: {}
}
}
}
default: {
debugLog('loadtracks', 4, { type: 3, loadType: 'unknown', sourceName: _getSourceName(type), query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
}
}
async function retrieveStream(identifier, type, title) {
if (!config.options.bypassAgeRestriction)
_switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
const { body: videos } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false&t=${randomLetters(12)}&id=${identifier}`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
cpn: randomLetters(16),
...(config.options.bypassAgeRestriction ? {
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: sourceInfo.signatureTimestamp
}
}
} : {}),
videoId: identifier,
contentCheckOk: true,
racyCheckOk: true,
params: 'CgIQBg'
},
method: 'POST',
disableBodyCompression: true
})
if (videos.playabilityStatus.status !== 'OK') {
debugLog('retrieveStream', 4, { type: 2, sourceName: _getSourceName(type), query: title, message: videos.playabilityStatus.reason })
return {
exception: {
message: videos.playabilityStatus.reason,
severity: 'common',
cause: 'Unknown'
}
}
}
let itag = null
switch (config.audio.quality) {
case 'high': itag = 251; break
case 'medium': itag = 250; break
case 'low': itag = 249; break
case 'lowest': itag = 599; break
default: itag = 251; break
}
const audio = videos.streamingData.adaptiveFormats.find((format) => format.itag === itag) || videos.streamingData.adaptiveFormats.find((format) => format.mimeType.startsWith('audio/'))
let url = audio.url || audio.signatureCipher || audio.cipher
if ((audio.signatureCipher || audio.cipher) && config.options.bypassAgeRestriction) {
const args = new URLSearchParams(url)
url = decodeURIComponent(args.get('url'))
if (audio.signatureCipher || audio.cipher)
url += `&${args.get('sp')}=${eval(`const sig = "${args.get('s')}";` + sourceInfo.functions[0])}`
} else {
url = decodeURIComponent(url)
}
url += `&rn=1&cpn=${randomLetters(16)}&ratebypass=yes&range=0-` /* range query is necessary to bypass throttling */
return {
url: videos.streamingData.hlsManifestUrl ? videos.streamingData.hlsManifestUrl : url,
protocol: videos.streamingData.hlsManifestUrl ? 'hls' : 'http',
format: audio.mimeType === 'audio/webm; codecs="opus"' ? 'webm/opus' : 'arbitrary'
}
}
function loadLyrics(decodedTrack, language) {
return new Promise(async (resolve) => {
if (!config.options.bypassAgeRestriction)
_switchClient(decodedTrack.sourceName === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
const { body: video } = await makeRequest(`https://${_getBaseHostRequest(decodedTrack.sourceName)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
headers: {
'User-Agent': ytContext.client.userAgent,
...(config.search.sources.youtube.authentication.enabled ? {
Authorization: config.search.sources.youtube.authentication.authorization,
Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
} : {})
},
body: {
context: ytContext,
videoId: decodedTrack.identifier,
contentCheckOk: true,
racyCheckOk: true,
params: 'CgIQBg'
},
method: 'POST'
})
if (video.playabilityStatus.status !== 'OK') {
debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: video.playabilityStatus.reason })
return resolve({
loadType: 'error',
data: {
message: video.playabilityStatus.reason,
severity: 'common',
cause: 'Unknown'
}
})
}
if (!video.captions)
return resolve(null)
const selectedCaption = video.captions.playerCaptionsTracklistRenderer.captionTracks.find((caption) => {
return caption.languageCode === language
})
if (selectedCaption) {
const { body: captionData } = await makeRequest(selectedCaption.baseUrl.replace('&fmt=srv3', '&fmt=json3'), { method: 'GET' }).catch((err) => {
debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: err.message })
return resolve({
loadType: 'error',
data: {
message: err.message,
severity: 'common',
cause: 'Unknown'
}
})
})
const captionEvents = []
captionData.events.forEach((event) => {
if (!event.segs) return null
captionEvents.push({
startTime: event.tStartMs,
endTime: event.tStartMs + (event.dDurationMs || 0),
text: event.segs ? event.segs.map((seg) => seg.utf8).join('') : null
})
})
return resolve({
loadType: 'lyricsSingle',
data: {
name: selectedCaption.languageCode,
synced: true,
data: captionEvents,
rtl: !!selectedCaption.rtl
}
})
} else {
const captions = []
let i = 0
video.captions.playerCaptionsTracklistRenderer.captionTracks.forEach(async (caption) => {
const { body: captionData } = await makeRequest(caption.baseUrl.replace('&fmt=srv3', '&fmt=json3'), { method: 'GET' }).catch((err) => {
debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: err.message })
return resolve({
loadType: 'error',
data: {
message: err.message,
severity: 'common',
cause: 'Unknown'
}
})
})
const captionEvents = captionData.events.map((event) => {
return {
startTime: event.tStartMs,
endTime: event.tStartMs + event.dDurationMs,
text: event.segs ? event.segs.map((seg) => seg.utf8).join('') : null
}
})
captions.push({
name: caption.languageCode,
synced: true,
data: captionEvents,
rtl: !!caption.rtl
})
if (++i === video.captions.playerCaptionsTracklistRenderer.captionTracks.length) {
if (captions.length === 0) {
debugLog('loadlyrics', 4, { type: 3, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: 'No captions found.' })
return resolve(null)
}
debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author } })
return resolve({
loadType: 'lyricsMultiple',
data: captions
})
}
})
}
})
}
async function loadStream(url) {
return new Promise(async (resolve) => {
const stream = new PassThrough()
await loadHLSPlaylist(url, stream)
resolve(stream)
})
}
export default {
init,
free,
search,
loadFrom,
retrieveStream,
loadLyrics,
loadStream
}