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,7 +198,78 @@ export default async function(o) {
} }
} }
let format = o.format || "h264"; const quality = o.quality === "max" ? 9000 : Number(o.quality);
const matchQuality = (resolution) => {
return resolution.height > resolution.width ? resolution.width : resolution.height;
}
let video, audio, isDubbed,
format = o.format || "h264";
if (o.youtubeHLS) {
const hlsManifest = info.streaming_data.hls_manifest_url;
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)
);
if (!variants || variants.length === 0) {
return { error: "content.video.no_streams" };
}
const matchHlsCodec = codecs => (
codecs.includes(hlsCodecList[format].videoCodec)
);
const best = variants.find(i => {
if (matchHlsCodec(i.codecs)) {
return i;
}
});
const preferred = variants.find((i) => {
if (matchHlsCodec(i.codecs) && matchQuality(i.resolution) === quality) {
return i;
}
});
const selected = preferred || best;
const defaultAudio = selected.audio.find(i => i.isDefault);
audio = defaultAudio;
if (o.dubLang) {
const dubbedAudio = selected.audio.find(i =>
i.language === o.dubLang
)
if (dubbedAudio && !dubbedAudio.isDefault) {
audio = dubbedAudio;
}
}
selected.audio = [];
selected.subtitles = [];
video = selected;
} else {
let fallback = false; let fallback = false;
const filterByCodec = (formats) => const filterByCodec = (formats) =>
@ -217,8 +303,7 @@ export default async function(o) {
return { error: "youtube.codec" }; return { error: "youtube.codec" };
} }
let audio = bestAudio; audio = bestAudio;
let isDubbed;
if (o.dubLang) { if (o.dubLang) {
const dubbedAudio = adaptive_formats.find(i => const dubbedAudio = adaptive_formats.find(i =>
@ -231,6 +316,24 @@ export default async function(o) {
} }
} }
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)
);
}
}
const fileMetadata = { const fileMetadata = {
title: cleanString(basicInfo.title.trim()), title: cleanString(basicInfo.title.trim()),
artist: cleanString(basicInfo.author.replace("- Topic", "").trim()) artist: cleanString(basicInfo.author.replace("- Topic", "").trim())
@ -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;
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.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = codecList[format].container; 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);
} }