Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

api/youtube: fall back through all codecs, refactor, catch no matching formats error #909

Merged
merged 9 commits into from
Nov 13, 2024
162 changes: 99 additions & 63 deletions api/src/processing/services/youtube.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const hlsCodecList = {
}
}

const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];

const transformSessionData = (cookie) => {
if (!cookie)
return;
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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";
if (codec === "vp9") codec = "av1";
wukko marked this conversation as resolved.
Show resolved Hide resolved

// 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;
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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",
Expand All @@ -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" };
}
6 changes: 6 additions & 0 deletions api/src/util/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@
"vk": [
{
"name": "clip, defaults",
"canFail": true,
"url": "https://vk.com/clip-57274055_456239788",
"params": {},
"expected": {
Expand All @@ -547,6 +548,7 @@
},
{
"name": "clip, 360",
"canFail": true,
"url": "https://vk.com/clip-57274055_456239788",
"params": {
"videoQuality": "360"
Expand All @@ -558,6 +560,7 @@
},
{
"name": "clip different link, max",
"canFail": true,
"url": "https://vk.com/clips-57274055?z=clip-57274055_456239788",
"params": {
"videoQuality": "max"
Expand All @@ -569,6 +572,7 @@
},
{
"name": "video, defaults",
"canFail": true,
"url": "https://vk.com/video-57274055_456239399",
"params": {},
"expected": {
Expand All @@ -578,6 +582,7 @@
},
{
"name": "4k video",
"canFail": true,
"url": "https://vk.com/video-1112285_456248465",
"params": {
"videoQuality": "max"
Expand All @@ -589,6 +594,7 @@
},
{
"name": "ancient video (fallback to 240p)",
"canFail": true,
"url": "https://vk.com/video-1959_28496479",
"params": {},
"expected": {
Expand Down
4 changes: 2 additions & 2 deletions web/i18n/en/error.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
Loading