api/youtube: add an option to use HLS streams

- added `youtubeHLS` variable to api
- added youtube HLS parsing & handling
This commit is contained in:
wukko 2024-10-28 15:17:54 +06:00
parent 24ae08b105
commit c9eefc4d55
No known key found for this signature in database
GPG key ID: 3E30B3F26C7B4AA2
5 changed files with 171 additions and 57 deletions

View file

@ -97,13 +97,14 @@ export default async function({ host, patternMatch, params }) {
case "youtube": case "youtube":
let fetchInfo = { let fetchInfo = {
dispatcher,
id: patternMatch.id.slice(0, 11), id: patternMatch.id.slice(0, 11),
quality: params.videoQuality, quality: params.videoQuality,
format: params.youtubeVideoCodec, format: params.youtubeVideoCodec,
isAudioOnly, isAudioOnly,
isAudioMuted, isAudioMuted,
dubLang: params.youtubeDubLang, dubLang: params.youtubeDubLang,
dispatcher youtubeHLS: params.youtubeHLS,
} }
if (url.hostname === "music.youtube.com" || isAudioOnly) { if (url.hostname === "music.youtube.com" || isAudioOnly) {

View file

@ -42,6 +42,8 @@ export const apiSchema = z.object({
tiktokFullAudio: z.boolean().default(false), tiktokFullAudio: z.boolean().default(false),
tiktokH265: z.boolean().default(false), tiktokH265: z.boolean().default(false),
twitterGif: z.boolean().default(true), twitterGif: z.boolean().default(true),
youtubeDubBrowserLang: z.boolean().default(false), youtubeDubBrowserLang: z.boolean().default(false),
youtubeHLS: z.boolean().default(false),
}) })
.strict(); .strict();

View file

@ -1,7 +1,7 @@
import UrlPattern from "url-pattern"; import UrlPattern from "url-pattern";
export const audioIgnore = ["vk", "ok", "loom"]; export const audioIgnore = ["vk", "ok", "loom"];
export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"]; export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"];
export const services = { export const services = {
bilibili: { bilibili: {

View file

@ -1,3 +1,5 @@
import HLS from "hls-parser";
import { fetch } from "undici"; import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js"; import { Innertube, Session } from "youtubei.js";
@ -27,6 +29,19 @@ const codecList = {
} }
} }
const hlsCodecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
container: "mp4"
},
vp9: {
videoCodec: "vp09",
audioCodec: "mp4a",
container: "mp4"
}
}
const transformSessionData = (cookie) => { const transformSessionData = (cookie) => {
if (!cookie) if (!cookie)
return; return;
@ -117,7 +132,7 @@ export default async function(o) {
let info; let info;
try { try {
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS'); info = await yt.getBasicInfo(o.id, o.youtubeHLS ? 'IOS' : 'ANDROID');
} catch(e) { } catch(e) {
if (e?.info?.reason === "This video is private") { if (e?.info?.reason === "This video is private") {
return { error: "content.video.private" }; return { error: "content.video.private" };
@ -183,51 +198,139 @@ export default async function(o) {
} }
} }
let format = o.format || "h264"; const quality = o.quality === "max" ? 9000 : Number(o.quality);
let fallback = false; const matchQuality = (resolution) => {
return resolution.height > resolution.width ? resolution.width : resolution.height;
}
const filterByCodec = (formats) => let video, audio, isDubbed,
formats.filter(e => format = o.format || "h264";
e.mime_type.includes(codecList[format].videoCodec)
|| e.mime_type.includes(codecList[format].audioCodec) if (o.youtubeHLS) {
).sort((a, b) => const hlsManifest = info.streaming_data.hls_manifest_url;
Number(b.bitrate) - Number(a.bitrate)
if (!hlsManifest) {
return { error: "content.video.no_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: "content.video.no_streams" };
}
const variants = HLS.parse(fetchedHlsManifest).variants.sort(
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
); );
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); if (!variants || variants.length === 0) {
return { error: "content.video.no_streams" };
}
const checkBestVideo = (i) => (i.has_video && i.content_length); const matchHlsCodec = codecs => (
const checkBestAudio = (i) => (i.has_audio && i.content_length && i.is_original); codecs.includes(hlsCodecList[format].videoCodec)
const checkNoMedia = (video, audio) => (!video && !o.isAudioOnly) || (!audio && o.isAudioOnly); );
const earlyBestVideo = adaptive_formats.find(i => checkBestVideo(i)); const best = variants.find(i => {
const earlyBestAudio = adaptive_formats.find(i => checkBestAudio(i)); if (matchHlsCodec(i.codecs)) {
return i;
}
});
// check if formats have all needed media and fall back to h264 if not const preferred = variants.find((i) => {
if (["vp9", "av1"].includes(format) && checkNoMedia(earlyBestVideo, earlyBestAudio)) { if (matchHlsCodec(i.codecs) && matchQuality(i.resolution) === quality) {
fallback = true; return i;
format = "h264"; }
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); });
}
const bestVideo = !fallback ? earlyBestVideo : adaptive_formats.find(i => checkBestVideo(i)); const selected = preferred || best;
const bestAudio = !fallback ? earlyBestAudio : adaptive_formats.find(i => checkBestAudio(i)); const defaultAudio = selected.audio.find(i => i.isDefault);
if (checkNoMedia(bestVideo, bestAudio)) { audio = defaultAudio;
return { error: "youtube.codec" };
}
let audio = bestAudio; if (o.dubLang) {
let isDubbed; const dubbedAudio = selected.audio.find(i =>
i.language === o.dubLang
)
if (o.dubLang) { if (dubbedAudio && !dubbedAudio.isDefault) {
const dubbedAudio = adaptive_formats.find(i => audio = dubbedAudio;
checkBestAudio(i) && i.language === o.dubLang && i.audio_track }
) }
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { selected.audio = [];
audio = dubbedAudio; selected.subtitles = [];
isDubbed = true; 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)
);
let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
const checkBestVideo = (i) => (i.has_video && i.content_length);
const checkBestAudio = (i) => (i.has_audio && i.content_length && i.is_original);
const checkNoMedia = (video, audio) => (!video && !o.isAudioOnly) || (!audio && o.isAudioOnly);
const earlyBestVideo = adaptive_formats.find(i => checkBestVideo(i));
const earlyBestAudio = adaptive_formats.find(i => checkBestAudio(i));
// 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);
}
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" };
}
audio = bestAudio;
if (o.dubLang) {
const 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 (!o.isAudioOnly) {
const qual = (i) => {
return matchQuality({
width: i.width,
height: i.height,
})
}
const quality = o.quality === "max" ? "9000" : o.quality;
const bestQuality = qual(bestVideo);
const useBestQuality = Number(quality) > Number(bestQuality);
video = useBestQuality ? bestVideo : adaptive_formats.find(i =>
qual(i) === quality && checkBestVideo(i)
);
} }
} }
@ -263,35 +366,43 @@ export default async function(o) {
filenameAttributes, filenameAttributes,
fileMetadata, fileMetadata,
bestAudio: format === "h264" ? "m4a" : "opus", bestAudio: format === "h264" ? "m4a" : "opus",
isHLS: o.youtubeHLS,
} }
const qual = (i) => {
if (!i.quality_label) return;
return i.quality_label.split('p', 2)[0].split('s', 2)[0];
}
const quality = o.quality === "max" ? "9000" : o.quality;
const bestQuality = qual(bestVideo);
const useBestQuality = Number(quality) > Number(bestQuality);
const video = useBestQuality ? bestVideo : adaptive_formats.find(i =>
qual(i) === quality && checkBestVideo(i)
);
if (video && audio) { if (video && audio) {
filenameAttributes.qualityLabel = video.quality_label; let resolution;
filenameAttributes.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = codecList[format].container; if (o.youtubeHLS) {
resolution = matchQuality(video.resolution);
filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
filenameAttributes.extension = hlsCodecList[format].container;
video = video.uri;
audio = audio.uri;
} else {
resolution = matchQuality({
width: video.width,
height: video.height,
});
filenameAttributes.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = codecList[format].container;
video = video.url;
audio = audio.url;
}
filenameAttributes.qualityLabel = `${resolution}p`;
filenameAttributes.youtubeFormat = format; filenameAttributes.youtubeFormat = format;
return { return {
type: "merge", type: "merge",
urls: [ urls: [
video.url, video,
audio.url audio,
], ],
filenameAttributes, filenameAttributes,
fileMetadata fileMetadata,
isHLS: o.youtubeHLS,
} }
} }

View file

@ -114,7 +114,7 @@ async function handleGenericStream(streamInfo, res) {
} }
export function internalStream(streamInfo, res) { export function internalStream(streamInfo, res) {
if (streamInfo.service === 'youtube') { if (streamInfo.service === 'youtube' && !streamInfo.isHLS) {
return handleYoutubeStream(streamInfo, res); return handleYoutubeStream(streamInfo, res);
} }