diff --git a/api/package.json b/api/package.json index 0498fe23..886df9fb 100644 --- a/api/package.json +++ b/api/package.json @@ -41,7 +41,8 @@ "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^11.0.1", + "youtube-dl-exec": "^3.0.12", + "youtubei.js": "^12.0.0", "zod": "^3.23.8" }, "optionalDependencies": { diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 8d2c1e38..cef2481c 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -59,6 +59,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab type: Array.isArray(r.urls) ? "merge" : "remux", isHLS: true, } + responseType = "redirect"; break; case "muteVideo": diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 57f04b36..954e4409 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -295,7 +295,8 @@ export default async function({ host, patternMatch, params }) { audioBitrate: params.audioBitrate, alwaysProxy: params.alwaysProxy, }) - } catch { + } catch (e) { + console.error(e); return createResponse("error", { code: "error.api.fetch.critical", context: { diff --git a/api/src/processing/request.js b/api/src/processing/request.js index d512bfe5..4fd7750f 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -71,7 +71,8 @@ export function createResponse(responseType, responseData) { ...response } } - } catch { + } catch (e) { + console.error(e); return internalError() } } diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 3af6ba94..a045e42d 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -1,487 +1,72 @@ -import HLS from "hls-parser"; - -import { fetch } from "undici"; -import { Innertube, Session } from "youtubei.js"; - -import { env } from "../../config.js"; -import { getCookie, updateCookieValues } from "../cookie/manager.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 videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; - -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; -} +import youtubedl from 'youtube-dl-exec'; +import { env } from '../../config.js'; 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 output = await youtubedl(`https://www.youtube.com/watch?v=${o.id}`, { + dumpSingleJson: true, + noCheckCertificates: true, + noWarnings: true, + addHeader: [ + 'referer:youtube.com', + 'user-agent:googlebot' + ] + }); - let useHLS = o.youtubeHLS; + const { is_live, duration, formats, title } = output; - // HLS playlists don't contain the av1 video format, at least with the iOS client - if (useHLS && o.format === "av1") { - useHLS = false; - } - - let info; - try { - info = await yt.getBasicInfo(o.id, useHLS ? 'IOS' : 'ANDROID'); - } catch (e) { - if (e?.info) { - const errorInfo = JSON.parse(e?.info); - - 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 (is_live) { + return { error: "content.video.live" }; } - if (e?.message === "This video is unavailable") { + if (duration > env.durationLimit) { + return { error: "content.too_long" }; + } + + const videoFormats = formats.filter(format => + format.vcodec !== 'none' && + format.acodec !== 'none' + ); + + if (videoFormats.length === 0) { + return { error: "youtube.no_matching_format" }; + } + + const qualityDict = videoFormats.reduce((acc, format) => { + const qualityLabel = format.height ? `${format.height}p` : "unknown"; + acc[qualityLabel] = { + url: format.url, + extension: format.ext, + resolution: format.height ? `${format.height}p` : "unknown", + youtubeFormat: format.ext + }; + return acc; + }, {}); + + return { + type: "merge", + urls: qualityDict, + filenameAttributes: { + title, + }, + fileMetadata: {}, + isHLS: true, + }; + } catch (e) { + console.error(e); + + if (e.message.includes("This video is unavailable")) { return { error: "content.video.unavailable" }; } + if (e.message.includes("Private video")) { + return { error: "content.video.private" }; + } + + if (e.message.includes("age verification")) { + return { error: "content.video.age" }; + } + 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")) { - 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 quality = o.quality === "max" ? 9000 : Number(o.quality); - - const normalizeQuality = res => { - const shortestSide = res.height > res.width ? res.width : res.height; - return videoQualities.find(qual => qual >= shortestSide); - } - - let video, audio, dubbedLanguage, - codec = o.format || "h264"; - - 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 sorted = sorted_formats[yCodec]; - const goodFormat = checkFormat(format, yCodec); - if (!goodFormat) return; - - if (format.has_video) { - sorted.video.push(format); - if (!sorted.bestVideo) sorted.bestVideo = format; - } - if (format.has_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; - } - } - - 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, - } - - if (audio && o.isAudioOnly) { - let bestAudio = codec === "h264" ? "m4a" : "opus"; - let urls = audio.url; - - if (useHLS) { - bestAudio = "mp3"; - urls = audio.uri; - } - - return { - type: "audio", - isAudioOnly: true, - urls, - filenameAttributes, - fileMetadata, - bestAudio, - isHLS: useHLS, - } - } - - 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; - - video = video.url; - audio = audio.url; - } - - filenameAttributes.qualityLabel = `${resolution}p`; - filenameAttributes.youtubeFormat = codec; - - return { - type: "merge", - urls: [ - video, - audio, - ], - filenameAttributes, - fileMetadata, - isHLS: useHLS, - } - } - - return { error: "youtube.no_matching_format" }; }