diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 005c3ed5a..ae96ea0df 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -42,6 +42,8 @@ const hlsCodecList = { } } +const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; + const transformSessionData = (cookie) => { if (!cookie) return; @@ -130,9 +132,16 @@ export default async function(o) { } else throw e; } + let useHLS = o.youtubeHLS; + + // 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, o.youtubeHLS ? 'IOS' : 'ANDROID'); + info = await yt.getBasicInfo(o.id, useHLS ? 'IOS' : 'ANDROID'); } catch(e) { if (e?.info?.reason === "This video is private") { return { error: "content.video.private" }; @@ -200,15 +209,15 @@ export default async function(o) { const quality = o.quality === "max" ? 9000 : Number(o.quality); - const matchQuality = res => { - const qual = res.height > res.width ? res.width : res.height; - return Math.ceil(qual / 24) * 24; + const normalizeQuality = res => { + const shortestSide = res.height > res.width ? res.width : res.height; + return videoQualities.find(qual => qual >= shortestSide); } let video, audio, dubbedLanguage, - format = o.format || "h264"; + codec = o.format || "h264"; - if (o.youtubeHLS) { + if (useHLS) { const hlsManifest = info.streaming_data.hls_manifest_url; if (!hlsManifest) { @@ -237,30 +246,25 @@ export default async function(o) { return { error: "youtube.no_hls_streams" }; } - // HLS playlists don't contain AV1 format, at least with the iOS client - if (format === "av1") { - format = "vp9"; - } - const matchHlsCodec = codecs => ( - codecs.includes(hlsCodecList[format].videoCodec) + codecs.includes(hlsCodecList[codec].videoCodec) ); const best = variants.find(i => matchHlsCodec(i.codecs)); const preferred = variants.find(i => - matchHlsCodec(i.codecs) && matchQuality(i.resolution) === quality + matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality ); let selected = preferred || best; if (!selected) { - format = "h264"; + codec = "h264"; selected = variants.find(i => matchHlsCodec(i.codecs)); } if (!selected) { - return { error: "youtube.no_hls_streams" }; + return { error: "youtube.no_matching_format" }; } audio = selected.audio.find(i => i.isDefault); @@ -286,51 +290,83 @@ export default async function(o) { selected.subtitles = []; video = selected; } else { - let fallback = false; - - const filterByCodec = (formats) => - formats.filter(e => - e.mime_type.includes(codecList[format].videoCodec) - || e.mime_type.includes(codecList[format].audioCodec) - ).sort((a, b) => - Number(b.bitrate) - Number(a.bitrate) - ); + // 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, + }, + } - let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + 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 checkBestVideo = (i) => (i.has_video && i.content_length); - const checkBestAudio = (i) => (i.has_audio && i.content_length); - const checkNoMedia = (vid, aud) => (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly); + const noBestMedia = () => { + const vid = sorted_formats[codec]?.bestVideo; + const aud = sorted_formats[codec]?.bestAudio; + return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly) + }; - const earlyBestVideo = adaptive_formats.find(i => checkBestVideo(i)); - const earlyBestAudio = adaptive_formats.find(i => checkBestAudio(i)); + if (noBestMedia()) { + if (codec === "av1") codec = "vp9"; + else if (codec === "vp9") codec = "av1"; - // check if formats have all needed media and fall back to h264 if not - if (["vp9", "av1"].includes(format) && checkNoMedia(earlyBestVideo, earlyBestAudio)) { - fallback = true; - format = "h264"; - adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + // if there's no higher quality fallback, then use h264 + if (noBestMedia()) codec = "h264"; } - const bestVideo = !fallback ? earlyBestVideo : adaptive_formats.find(i => checkBestVideo(i)); - const bestAudio = !fallback ? earlyBestAudio : adaptive_formats.find(i => checkBestAudio(i)); - - if (checkNoMedia(bestVideo, bestAudio)) { - return { error: "youtube.codec" }; + // if there's no proper combo of av1, vp9, or h264, then give up + if (noBestMedia()) { + return { error: "youtube.no_matching_format" }; } - audio = bestAudio; + audio = sorted_formats[codec].bestAudio; if (audio?.audio_track && !audio?.audio_track?.audio_is_default) { - audio = adaptive_formats.find(i => - checkBestAudio(i) && i?.audio_track?.audio_is_default + audio = sorted_formats[codec].audio.find(i => + i?.audio_track?.audio_is_default ); } if (o.dubLang) { - const dubbedAudio = adaptive_formats.find(i => - checkBestAudio(i) && i.language?.startsWith(o.dubLang) && i.audio_track - ) + 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; @@ -340,20 +376,20 @@ export default async function(o) { if (!o.isAudioOnly) { const qual = (i) => { - return matchQuality({ + return normalizeQuality({ width: i.width, height: i.height, }) } - const bestQuality = qual(bestVideo); + const bestQuality = qual(sorted_formats[codec].bestVideo); const useBestQuality = quality >= bestQuality; - video = useBestQuality ? bestVideo : adaptive_formats.find(i => - qual(i) === quality && checkBestVideo(i) - ); + video = useBestQuality + ? sorted_formats[codec].bestVideo + : sorted_formats[codec].video.find(i => qual(i) === quality); - if (!video) video = bestVideo; + if (!video) video = sorted_formats[codec].bestVideo; } } @@ -383,10 +419,10 @@ export default async function(o) { } if (audio && o.isAudioOnly) { - let bestAudio = format === "h264" ? "m4a" : "opus"; + let bestAudio = codec === "h264" ? "m4a" : "opus"; let urls = audio.url; - if (o.youtubeHLS) { + if (useHLS) { bestAudio = "mp3"; urls = audio.uri; } @@ -398,34 +434,34 @@ export default async function(o) { filenameAttributes, fileMetadata, bestAudio, - isHLS: o.youtubeHLS, + isHLS: useHLS, } } if (video && audio) { let resolution; - if (o.youtubeHLS) { - resolution = matchQuality(video.resolution); + if (useHLS) { + resolution = normalizeQuality(video.resolution); filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`; - filenameAttributes.extension = hlsCodecList[format].container; + filenameAttributes.extension = hlsCodecList[codec].container; video = video.uri; audio = audio.uri; } else { - resolution = matchQuality({ + resolution = normalizeQuality({ width: video.width, height: video.height, }); filenameAttributes.resolution = `${video.width}x${video.height}`; - filenameAttributes.extension = codecList[format].container; + filenameAttributes.extension = codecList[codec].container; video = video.url; audio = audio.url; } filenameAttributes.qualityLabel = `${resolution}p`; - filenameAttributes.youtubeFormat = format; + filenameAttributes.youtubeFormat = codec; return { type: "merge", @@ -435,9 +471,9 @@ export default async function(o) { ], filenameAttributes, fileMetadata, - isHLS: o.youtubeHLS, + isHLS: useHLS, } } - return { error: "fetch.fail" }; + return { error: "youtube.no_matching_format" }; } diff --git a/api/src/util/tests.json b/api/src/util/tests.json index 3914deece..72f00ad42 100644 --- a/api/src/util/tests.json +++ b/api/src/util/tests.json @@ -538,6 +538,7 @@ "vk": [ { "name": "clip, defaults", + "canFail": true, "url": "https://vk.com/clip-57274055_456239788", "params": {}, "expected": { @@ -547,6 +548,7 @@ }, { "name": "clip, 360", + "canFail": true, "url": "https://vk.com/clip-57274055_456239788", "params": { "videoQuality": "360" @@ -558,6 +560,7 @@ }, { "name": "clip different link, max", + "canFail": true, "url": "https://vk.com/clips-57274055?z=clip-57274055_456239788", "params": { "videoQuality": "max" @@ -569,6 +572,7 @@ }, { "name": "video, defaults", + "canFail": true, "url": "https://vk.com/video-57274055_456239399", "params": {}, "expected": { @@ -578,6 +582,7 @@ }, { "name": "4k video", + "canFail": true, "url": "https://vk.com/video-1112285_456248465", "params": { "videoQuality": "max" @@ -589,6 +594,7 @@ }, { "name": "ancient video (fallback to 240p)", + "canFail": true, "url": "https://vk.com/video-1959_28496479", "params": {}, "expected": { diff --git a/web/i18n/en/error.json b/web/i18n/en/error.json index 5ac0ecf61..638caa4a5 100644 --- a/web/i18n/en/error.json +++ b/web/i18n/en/error.json @@ -47,9 +47,9 @@ "api.content.post.private": "this post is from a private account, so i can't access it. have you pasted the right link?", "api.content.post.age": "this post is age-restricted, so i can't access it anonymously. have you pasted the right link?", - "api.youtube.codec": "youtube didn't return anything with your preferred video codec. try another one in settings!", + "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo. either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them.", "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video.\n\ntry again in a few seconds, but if issue sticks, contact us for support.", "api.youtube.login": "couldn't get this video because youtube labeled me as a bot. this is potentially caused by the processing instance not having any active account tokens. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", - "api.youtube.no_hls_streams": "couldn't find any matching HLS streams. try different settings!" + "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!" }