mirror of
https://github.com/wukko/cobalt.git
synced 2024-11-15 12:50:01 +00:00
api/youtube: add an option to use HLS streams
- added `youtubeHLS` variable to api - added youtube HLS parsing & handling
This commit is contained in:
parent
24ae08b105
commit
c9eefc4d55
5 changed files with 171 additions and 57 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue