Spaces:
Paused
Paused
| import HLS from "hls-parser"; | |
| import { fetch } from "undici"; | |
| import { Innertube, Session } from "youtubei.js"; | |
| import { env } from "../../config.js"; | |
| import { getCookie } from "../cookie/manager.js"; | |
| import { getYouTubeSession } from "../helpers/youtube-session.js"; | |
| const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms | |
| let innertube, lastRefreshedAt; | |
| const codecList = { | |
| h264: { | |
| videoCodec: "avc1", | |
| audioCodec: "mp4a", | |
| container: "mp4" | |
| }, | |
| av1: { | |
| videoCodec: "av01", | |
| audioCodec: "opus", | |
| container: "webm" | |
| }, | |
| vp9: { | |
| videoCodec: "vp9", | |
| audioCodec: "opus", | |
| container: "webm" | |
| } | |
| } | |
| const hlsCodecList = { | |
| h264: { | |
| videoCodec: "avc1", | |
| audioCodec: "mp4a", | |
| container: "mp4" | |
| }, | |
| vp9: { | |
| videoCodec: "vp09", | |
| audioCodec: "mp4a", | |
| container: "webm" | |
| } | |
| } | |
| const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID']; | |
| const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; | |
| const cloneInnertube = async (customFetch, useSession) => { | |
| const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date(); | |
| const rawCookie = getCookie('youtube'); | |
| const cookie = rawCookie?.toString(); | |
| const sessionTokens = getYouTubeSession(); | |
| const retrieve_player = Boolean(sessionTokens || cookie); | |
| if (useSession && env.ytSessionServer && !sessionTokens?.potoken) { | |
| throw "no_session_tokens"; | |
| } | |
| if (!innertube || shouldRefreshPlayer) { | |
| innertube = await Innertube.create({ | |
| fetch: customFetch, | |
| retrieve_player, | |
| cookie, | |
| po_token: useSession ? sessionTokens?.potoken : undefined, | |
| visitor_data: useSession ? sessionTokens?.visitor_data : undefined, | |
| }); | |
| lastRefreshedAt = +new Date(); | |
| } | |
| const session = new Session( | |
| innertube.session.context, | |
| innertube.session.key, | |
| innertube.session.api_version, | |
| innertube.session.account_index, | |
| innertube.session.player, | |
| cookie, | |
| customFetch ?? innertube.session.http.fetch, | |
| innertube.session.cache | |
| ); | |
| const yt = new Innertube(session); | |
| return yt; | |
| } | |
| export default async function (o) { | |
| const quality = o.quality === "max" ? 9000 : Number(o.quality); | |
| let useHLS = o.youtubeHLS; | |
| let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS"; | |
| // HLS playlists from the iOS client don't contain the av1 video format. | |
| if (useHLS && o.format === "av1") { | |
| useHLS = false; | |
| } | |
| if (useHLS) { | |
| innertubeClient = "IOS"; | |
| } | |
| // iOS client doesn't have adaptive formats of resolution >1080p, | |
| // so we use the WEB_EMBEDDED client instead for those cases | |
| const useSession = | |
| env.ytSessionServer && ( | |
| ( | |
| !useHLS | |
| && innertubeClient === "IOS" | |
| && ( | |
| (quality > 1080 && o.format !== "h264") | |
| || (quality > 1080 && o.format !== "vp9") | |
| ) | |
| ) | |
| ); | |
| if (useSession) { | |
| innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED"; | |
| } | |
| let yt; | |
| try { | |
| yt = await cloneInnertube( | |
| (input, init) => fetch(input, { | |
| ...init, | |
| dispatcher: o.dispatcher | |
| }), | |
| useSession | |
| ); | |
| } catch (e) { | |
| if (e === "no_session_tokens") { | |
| return { error: "youtube.no_session_tokens" }; | |
| } else 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; | |
| } | |
| let info; | |
| try { | |
| info = await yt.getBasicInfo(o.id, innertubeClient); | |
| } catch (e) { | |
| if (e?.info) { | |
| let errorInfo; | |
| try { errorInfo = JSON.parse(e?.info); } catch {} | |
| if (errorInfo?.reason === "This video is private") { | |
| return { error: "content.video.private" }; | |
| } | |
| if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) { | |
| return { error: "youtube.api_error" }; | |
| } | |
| } | |
| if (e?.message === "This video is unavailable") { | |
| return { error: "content.video.unavailable" }; | |
| } | |
| return { error: "fetch.fail" }; | |
| } | |
| if (!info) return { error: "fetch.fail" }; | |
| const playability = info.playability_status; | |
| const basicInfo = info.basic_info; | |
| switch (playability.status) { | |
| case "LOGIN_REQUIRED": | |
| if (playability.reason.endsWith("bot")) { | |
| return { error: "youtube.login" } | |
| } | |
| if (playability.reason.endsWith("age") || playability.reason.endsWith("inappropriate for some users.")) { | |
| return { error: "content.video.age" } | |
| } | |
| if (playability?.error_screen?.reason?.text === "Private video") { | |
| return { error: "content.video.private" } | |
| } | |
| break; | |
| case "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" } | |
| } | |
| break; | |
| case "AGE_VERIFICATION_REQUIRED": | |
| return { error: "content.video.age" }; | |
| } | |
| if (playability.status !== "OK") { | |
| return { error: "content.video.unavailable" }; | |
| } | |
| if (basicInfo.is_live) { | |
| return { error: "content.video.live" }; | |
| } | |
| if (basicInfo.duration > env.durationLimit) { | |
| return { error: "content.too_long" }; | |
| } | |
| // 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 normalizeQuality = res => { | |
| const shortestSide = Math.min(res.height, res.width); | |
| return videoQualities.find(qual => qual >= shortestSide); | |
| } | |
| let video, audio, dubbedLanguage, | |
| codec = o.format || "h264", itag = o.itag; | |
| if (useHLS) { | |
| const hlsManifest = info.streaming_data.hls_manifest_url; | |
| if (!hlsManifest) { | |
| return { error: "youtube.no_hls_streams" }; | |
| } | |
| const fetchedHlsManifest = await fetch(hlsManifest, { | |
| dispatcher: o.dispatcher, | |
| }).then(r => { | |
| if (r.status === 200) { | |
| return r.text(); | |
| } else { | |
| throw new Error("couldn't fetch the HLS playlist"); | |
| } | |
| }).catch(() => { }); | |
| if (!fetchedHlsManifest) { | |
| return { error: "youtube.no_hls_streams" }; | |
| } | |
| const variants = HLS.parse(fetchedHlsManifest).variants.sort( | |
| (a, b) => Number(b.bandwidth) - Number(a.bandwidth) | |
| ); | |
| if (!variants || variants.length === 0) { | |
| return { error: "youtube.no_hls_streams" }; | |
| } | |
| const matchHlsCodec = codecs => ( | |
| codecs.includes(hlsCodecList[codec].videoCodec) | |
| ); | |
| const best = variants.find(i => matchHlsCodec(i.codecs)); | |
| const preferred = variants.find(i => | |
| matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality | |
| ); | |
| let selected = preferred || best; | |
| if (!selected) { | |
| codec = "h264"; | |
| selected = variants.find(i => matchHlsCodec(i.codecs)); | |
| } | |
| if (!selected) { | |
| return { error: "youtube.no_matching_format" }; | |
| } | |
| audio = selected.audio.find(i => i.isDefault); | |
| // some videos (mainly those with AI dubs) don't have any tracks marked as default | |
| // why? god knows, but we assume that a default track is marked as such in the title | |
| if (!audio) { | |
| audio = selected.audio.find(i => i.name.endsWith("- original")); | |
| } | |
| if (o.dubLang) { | |
| const dubbedAudio = selected.audio.find(i => | |
| i.language?.startsWith(o.dubLang) | |
| ); | |
| if (dubbedAudio && !dubbedAudio.isDefault) { | |
| dubbedLanguage = dubbedAudio.language; | |
| audio = dubbedAudio; | |
| } | |
| } | |
| selected.audio = []; | |
| selected.subtitles = []; | |
| video = selected; | |
| } else { | |
| // i miss typescript so bad | |
| const sorted_formats = { | |
| h264: { | |
| video: [], | |
| audio: [], | |
| bestVideo: undefined, | |
| bestAudio: undefined, | |
| }, | |
| vp9: { | |
| video: [], | |
| audio: [], | |
| bestVideo: undefined, | |
| bestAudio: undefined, | |
| }, | |
| av1: { | |
| video: [], | |
| audio: [], | |
| bestVideo: undefined, | |
| bestAudio: undefined, | |
| }, | |
| } | |
| const checkFormat = (format, pCodec) => format.content_length && | |
| (format.mime_type.includes(codecList[pCodec].videoCodec) | |
| || format.mime_type.includes(codecList[pCodec].audioCodec)); | |
| // sort formats & weed out bad ones | |
| info.streaming_data.adaptive_formats.sort((a, b) => | |
| Number(b.bitrate) - Number(a.bitrate) | |
| ).forEach(format => { | |
| Object.keys(codecList).forEach(yCodec => { | |
| const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag; | |
| const sorted = sorted_formats[yCodec]; | |
| const goodFormat = checkFormat(format, yCodec); | |
| if (!goodFormat) return; | |
| if (format.has_video && matchingItag('video')) { | |
| sorted.video.push(format); | |
| if (!sorted.bestVideo) | |
| sorted.bestVideo = format; | |
| } | |
| if (format.has_audio && matchingItag('audio')) { | |
| sorted.audio.push(format); | |
| if (!sorted.bestAudio) | |
| sorted.bestAudio = format; | |
| } | |
| }) | |
| }); | |
| const noBestMedia = () => { | |
| const vid = sorted_formats[codec]?.bestVideo; | |
| const aud = sorted_formats[codec]?.bestAudio; | |
| return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly) | |
| }; | |
| if (noBestMedia()) { | |
| if (codec === "av1") codec = "vp9"; | |
| else if (codec === "vp9") codec = "av1"; | |
| // if there's no higher quality fallback, then use h264 | |
| if (noBestMedia()) codec = "h264"; | |
| } | |
| // if there's no proper combo of av1, vp9, or h264, then give up | |
| if (noBestMedia()) { | |
| return { error: "youtube.no_matching_format" }; | |
| } | |
| audio = sorted_formats[codec].bestAudio; | |
| if (audio?.audio_track && !audio?.audio_track?.audio_is_default) { | |
| audio = sorted_formats[codec].audio.find(i => | |
| i?.audio_track?.audio_is_default | |
| ); | |
| } | |
| if (o.dubLang) { | |
| const dubbedAudio = sorted_formats[codec].audio.find(i => | |
| i.language?.startsWith(o.dubLang) && i.audio_track | |
| ); | |
| if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { | |
| audio = dubbedAudio; | |
| dubbedLanguage = dubbedAudio.language; | |
| } | |
| } | |
| if (!o.isAudioOnly) { | |
| const qual = (i) => { | |
| return normalizeQuality({ | |
| width: i.width, | |
| height: i.height, | |
| }) | |
| } | |
| const bestQuality = qual(sorted_formats[codec].bestVideo); | |
| const useBestQuality = quality >= bestQuality; | |
| video = useBestQuality | |
| ? sorted_formats[codec].bestVideo | |
| : sorted_formats[codec].video.find(i => qual(i) === quality); | |
| if (!video) video = sorted_formats[codec].bestVideo; | |
| } | |
| } | |
| if (video?.drm_families || audio?.drm_families) { | |
| return { error: "youtube.drm" }; | |
| } | |
| const fileMetadata = { | |
| title: basicInfo.title.trim(), | |
| artist: basicInfo.author.replace("- Topic", "").trim() | |
| } | |
| if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) { | |
| const descItems = basicInfo.short_description.split("\n\n", 5); | |
| if (descItems.length === 5) { | |
| fileMetadata.album = descItems[2]; | |
| fileMetadata.copyright = descItems[3]; | |
| if (descItems[4].startsWith("Released on:")) { | |
| fileMetadata.date = descItems[4].replace("Released on: ", '').trim(); | |
| } | |
| } | |
| } | |
| const filenameAttributes = { | |
| service: "youtube", | |
| id: o.id, | |
| title: fileMetadata.title, | |
| author: fileMetadata.artist, | |
| youtubeDubName: dubbedLanguage || false, | |
| } | |
| itag = { | |
| video: video?.itag, | |
| audio: audio?.itag | |
| }; | |
| const originalRequest = { | |
| ...o, | |
| dispatcher: undefined, | |
| itag, | |
| innertubeClient | |
| }; | |
| if (audio && o.isAudioOnly) { | |
| let bestAudio = codec === "h264" ? "m4a" : "opus"; | |
| let urls = audio.url; | |
| if (useHLS) { | |
| bestAudio = "mp3"; | |
| urls = audio.uri; | |
| } | |
| if (!clientsWithNoCipher.includes(innertubeClient) && innertube) { | |
| urls = audio.decipher(innertube.session.player); | |
| } | |
| return { | |
| type: "audio", | |
| isAudioOnly: true, | |
| urls, | |
| filenameAttributes, | |
| fileMetadata, | |
| bestAudio, | |
| isHLS: useHLS, | |
| originalRequest | |
| } | |
| } | |
| if (video && audio) { | |
| let resolution; | |
| if (useHLS) { | |
| resolution = normalizeQuality(video.resolution); | |
| filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`; | |
| filenameAttributes.extension = hlsCodecList[codec].container; | |
| video = video.uri; | |
| audio = audio.uri; | |
| } else { | |
| resolution = normalizeQuality({ | |
| width: video.width, | |
| height: video.height, | |
| }); | |
| filenameAttributes.resolution = `${video.width}x${video.height}`; | |
| filenameAttributes.extension = codecList[codec].container; | |
| if (!clientsWithNoCipher.includes(innertubeClient) && innertube) { | |
| video = video.decipher(innertube.session.player); | |
| audio = audio.decipher(innertube.session.player); | |
| } else { | |
| video = video.url; | |
| audio = audio.url; | |
| } | |
| } | |
| filenameAttributes.qualityLabel = `${resolution}p`; | |
| filenameAttributes.youtubeFormat = codec; | |
| return { | |
| type: "merge", | |
| urls: [ | |
| video, | |
| audio, | |
| ], | |
| filenameAttributes, | |
| fileMetadata, | |
| isHLS: useHLS, | |
| originalRequest | |
| } | |
| } | |
| return { error: "youtube.no_matching_format" }; | |
| } | |