Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Video-Downloader/api/src/processing/services/youtube.js
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
333 lines (279 sloc)
9.34 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { fetch } from 'undici'; | |
import { Innertube, Session } from 'youtubei.js'; | |
import { env } from '../../config.js'; | |
import { cleanString } from '../../misc/utils.js'; | |
import { getCookie, updateCookieValues } from '../cookie/manager.js'; | |
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms | |
let innertube, lastRefreshedAt; | |
const codecMatch = { | |
h264: { | |
videoCodec: 'avc1', | |
audioCodec: 'mp4a', | |
container: 'mp4', | |
}, | |
av1: { | |
videoCodec: 'av01', | |
audioCodec: 'opus', | |
container: 'webm', | |
}, | |
vp9: { | |
videoCodec: 'vp9', | |
audioCodec: 'opus', | |
container: 'webm', | |
}, | |
}; | |
const transformSessionData = (cookie) => { | |
if (!cookie) return; | |
const values = { ...cookie.values() }; | |
const REQUIRED_VALUES = ['access_token', 'refresh_token']; | |
if (REQUIRED_VALUES.some((x) => typeof values[x] !== 'string')) { | |
return; | |
} | |
if (values.expires) { | |
values.expiry_date = values.expires; | |
delete values.expires; | |
} else if (!values.expiry_date) { | |
return; | |
} | |
return values; | |
}; | |
const cloneInnertube = async (customFetch) => { | |
const shouldRefreshPlayer = | |
lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date(); | |
if (!innertube || shouldRefreshPlayer) { | |
innertube = await Innertube.create({ | |
fetch: customFetch, | |
}); | |
lastRefreshedAt = +new Date(); | |
} | |
const session = new Session( | |
innertube.session.context, | |
innertube.session.key, | |
innertube.session.api_version, | |
innertube.session.account_index, | |
innertube.session.player, | |
undefined, | |
customFetch ?? innertube.session.http.fetch, | |
innertube.session.cache, | |
); | |
const cookie = getCookie('youtube_oauth'); | |
const oauthData = transformSessionData(cookie); | |
if (!session.logged_in && oauthData) { | |
await session.oauth.init(oauthData); | |
session.logged_in = true; | |
} | |
if (session.logged_in) { | |
if (session.oauth.shouldRefreshToken()) { | |
await session.oauth.refreshAccessToken(); | |
} | |
const cookieValues = cookie.values(); | |
const oldExpiry = new Date(cookieValues.expiry_date); | |
const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date); | |
if (oldExpiry.getTime() !== newExpiry.getTime()) { | |
updateCookieValues(cookie, { | |
...session.oauth.client_id, | |
...session.oauth.oauth2_tokens, | |
expiry_date: newExpiry.toISOString(), | |
}); | |
} | |
} | |
const yt = new Innertube(session); | |
return yt; | |
}; | |
export default async function (o) { | |
let yt; | |
try { | |
yt = await cloneInnertube((input, init) => | |
fetch(input, { | |
...init, | |
dispatcher: o.dispatcher, | |
}), | |
); | |
} catch (e) { | |
if (e.message?.endsWith('decipher algorithm')) { | |
return { error: 'youtube.decipher' }; | |
} else if (e.message?.includes('refresh access token')) { | |
return { error: 'youtube.token_expired' }; | |
} else throw e; | |
} | |
const quality = o.quality === 'max' ? '9000' : o.quality; | |
let info, | |
isDubbed, | |
format = o.format || 'h264'; | |
function qual(i) { | |
if (!i.quality_label) { | |
return; | |
} | |
return i.quality_label.split('p')[0].split('s')[0]; | |
} | |
try { | |
info = await yt.getBasicInfo( | |
o.id, | |
yt.session.logged_in ? 'ANDROID' : 'IOS', | |
); | |
} catch (e) { | |
if (e?.info?.reason === 'This video is private') { | |
return { error: 'content.video.private' }; | |
} else if (e?.message === 'This video is unavailable') { | |
return { error: 'content.video.unavailable' }; | |
} else { | |
return { error: 'fetch.fail' }; | |
} | |
} | |
if (!info) return { error: 'fetch.fail' }; | |
const playability = info.playability_status; | |
const basicInfo = info.basic_info; | |
if (playability.status === 'LOGIN_REQUIRED') { | |
if (playability.reason.endsWith('bot')) { | |
return { error: 'youtube.login' }; | |
} | |
if (playability.reason.endsWith('age')) { | |
return { error: 'content.video.age' }; | |
} | |
if (playability?.error_screen?.reason?.text === 'Private video') { | |
return { error: 'content.video.private' }; | |
} | |
} | |
if (playability.status === 'UNPLAYABLE') { | |
if (playability?.reason?.endsWith('request limit.')) { | |
return { error: 'fetch.rate' }; | |
} | |
if ( | |
playability?.error_screen?.subreason?.text?.endsWith( | |
'in your country', | |
) | |
) { | |
return { error: 'content.video.region' }; | |
} | |
if (playability?.error_screen?.reason?.text === 'Private video') { | |
return { error: 'content.video.private' }; | |
} | |
} | |
if (playability.status !== 'OK') { | |
return { error: 'content.video.unavailable' }; | |
} | |
if (basicInfo.is_live) { | |
return { error: 'content.video.live' }; | |
} | |
// return a critical error if returned video is "Video Not Available" | |
// or a similar stub by youtube | |
if (basicInfo.id !== o.id) { | |
return { | |
error: 'fetch.fail', | |
critical: true, | |
}; | |
} | |
const filterByCodec = (formats) => | |
formats | |
.filter( | |
(e) => | |
e.mime_type.includes(codecMatch[format].videoCodec) || | |
e.mime_type.includes(codecMatch[format].audioCodec), | |
) | |
.sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); | |
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); | |
if (adaptive_formats.length === 0 && format === 'vp9') { | |
format = 'h264'; | |
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); | |
} | |
let bestQuality; | |
const bestVideo = adaptive_formats.find( | |
(i) => i.has_video && i.content_length, | |
); | |
const hasAudio = adaptive_formats.find( | |
(i) => i.has_audio && i.content_length, | |
); | |
if (bestVideo) bestQuality = qual(bestVideo); | |
if ((!bestQuality && !o.isAudioOnly) || !hasAudio) | |
return { error: 'youtube.codec' }; | |
if (basicInfo.duration > env.durationLimit) | |
return { error: 'content.too_long' }; | |
const checkBestAudio = (i) => i.has_audio && !i.has_video; | |
let audio = adaptive_formats.find( | |
(i) => checkBestAudio(i) && i.is_original, | |
); | |
if (o.dubLang) { | |
let dubbedAudio = adaptive_formats.find( | |
(i) => | |
checkBestAudio(i) && i.language === o.dubLang && i.audio_track, | |
); | |
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { | |
audio = dubbedAudio; | |
isDubbed = true; | |
} | |
} | |
if (!audio) { | |
audio = adaptive_formats.find((i) => checkBestAudio(i)); | |
} | |
let fileMetadata = { | |
title: cleanString(basicInfo.title.trim()), | |
artist: cleanString(basicInfo.author.replace('- Topic', '').trim()), | |
}; | |
if (basicInfo?.short_description?.startsWith('Provided to YouTube by')) { | |
let descItems = basicInfo.short_description.split('\n\n'); | |
fileMetadata.album = descItems[2]; | |
fileMetadata.copyright = descItems[3]; | |
if (descItems[4].startsWith('Released on:')) { | |
fileMetadata.date = descItems[4] | |
.replace('Released on: ', '') | |
.trim(); | |
} | |
} | |
let filenameAttributes = { | |
service: 'youtube', | |
id: o.id, | |
title: fileMetadata.title, | |
author: fileMetadata.artist, | |
youtubeDubName: isDubbed ? o.dubLang : false, | |
}; | |
if (audio && o.isAudioOnly) | |
return { | |
type: 'audio', | |
isAudioOnly: true, | |
urls: audio.decipher(yt.session.player), | |
filenameAttributes: filenameAttributes, | |
fileMetadata: fileMetadata, | |
bestAudio: format === 'h264' ? 'm4a' : 'opus', | |
}; | |
const matchingQuality = | |
Number(quality) > Number(bestQuality) ? bestQuality : quality, | |
checkSingle = (i) => | |
qual(i) === matchingQuality && | |
i.mime_type.includes(codecMatch[format].videoCodec), | |
checkRender = (i) => | |
qual(i) === matchingQuality && i.has_video && !i.has_audio; | |
let match, type, urls; | |
// prefer good premuxed videos if available | |
if ( | |
!o.isAudioOnly && | |
!o.isAudioMuted && | |
format === 'h264' && | |
bestVideo.fps <= 30 | |
) { | |
match = info.streaming_data.formats.find(checkSingle); | |
type = 'proxy'; | |
urls = match?.decipher(yt.session.player); | |
} | |
const video = adaptive_formats.find(checkRender); | |
if (!match && video && audio) { | |
match = video; | |
type = 'merge'; | |
urls = [ | |
video.decipher(yt.session.player), | |
audio.decipher(yt.session.player), | |
]; | |
} | |
if (match) { | |
filenameAttributes.qualityLabel = match.quality_label; | |
filenameAttributes.resolution = `${match.width}x${match.height}`; | |
filenameAttributes.extension = codecMatch[format].container; | |
filenameAttributes.youtubeFormat = format; | |
return { | |
type, | |
urls, | |
filenameAttributes, | |
fileMetadata, | |
}; | |
} | |
return { error: 'fetch.fail' }; | |
} |