diff --git a/api/src/processing/match.js b/api/src/processing/match.js index b0022d08..8500c3b5 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -97,13 +97,14 @@ export default async function({ host, patternMatch, params }) { case "youtube": let fetchInfo = { + dispatcher, id: patternMatch.id.slice(0, 11), quality: params.videoQuality, format: params.youtubeVideoCodec, isAudioOnly, isAudioMuted, dubLang: params.youtubeDubLang, - dispatcher + youtubeHLS: params.youtubeHLS, } if (url.hostname === "music.youtube.com" || isAudioOnly) { diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 172d480c..f7493325 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -42,6 +42,8 @@ export const apiSchema = z.object({ tiktokFullAudio: z.boolean().default(false), tiktokH265: z.boolean().default(false), twitterGif: z.boolean().default(true), + youtubeDubBrowserLang: z.boolean().default(false), + youtubeHLS: z.boolean().default(false), }) .strict(); diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index a2136ad0..fc437095 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -1,7 +1,7 @@ import UrlPattern from "url-pattern"; export const audioIgnore = ["vk", "ok", "loom"]; -export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"]; +export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"]; export const services = { bilibili: { diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index c045dce0..af8d8c3d 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -1,3 +1,5 @@ +import HLS from "hls-parser"; + import { fetch } from "undici"; 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) => { if (!cookie) return; @@ -117,7 +132,7 @@ export default async function(o) { let info; 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) { if (e?.info?.reason === "This video is private") { return { error: "content.video.private" }; @@ -183,51 +198,139 @@ export default async function(o) { } } - let format = o.format || "h264"; - let fallback = false; + const quality = o.quality === "max" ? 9000 : Number(o.quality); + const matchQuality = (resolution) => { + return resolution.height > resolution.width ? resolution.width : resolution.height; + } - 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 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) ); - 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 checkBestAudio = (i) => (i.has_audio && i.content_length && i.is_original); - const checkNoMedia = (video, audio) => (!video && !o.isAudioOnly) || (!audio && o.isAudioOnly); + const matchHlsCodec = codecs => ( + codecs.includes(hlsCodecList[format].videoCodec) + ); - const earlyBestVideo = adaptive_formats.find(i => checkBestVideo(i)); - const earlyBestAudio = adaptive_formats.find(i => checkBestAudio(i)); + const best = variants.find(i => { + if (matchHlsCodec(i.codecs)) { + return 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 preferred = variants.find((i) => { + if (matchHlsCodec(i.codecs) && matchQuality(i.resolution) === quality) { + return i; + } + }); - const bestVideo = !fallback ? earlyBestVideo : adaptive_formats.find(i => checkBestVideo(i)); - const bestAudio = !fallback ? earlyBestAudio : adaptive_formats.find(i => checkBestAudio(i)); + const selected = preferred || best; + const defaultAudio = selected.audio.find(i => i.isDefault); - if (checkNoMedia(bestVideo, bestAudio)) { - return { error: "youtube.codec" }; - } + audio = defaultAudio; - let audio = bestAudio; - let isDubbed; + if (o.dubLang) { + const dubbedAudio = selected.audio.find(i => + i.language === o.dubLang + ) - if (o.dubLang) { - const dubbedAudio = adaptive_formats.find(i => - checkBestAudio(i) && i.language === o.dubLang && i.audio_track - ) + if (dubbedAudio && !dubbedAudio.isDefault) { + audio = dubbedAudio; + } + } - if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { - audio = dubbedAudio; - isDubbed = true; + selected.audio = []; + 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) + ); + + 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, fileMetadata, 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) { - filenameAttributes.qualityLabel = video.quality_label; - filenameAttributes.resolution = `${video.width}x${video.height}`; - filenameAttributes.extension = codecList[format].container; + 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.extension = codecList[format].container; + + video = video.url; + audio = audio.url; + } + + filenameAttributes.qualityLabel = `${resolution}p`; filenameAttributes.youtubeFormat = format; return { type: "merge", urls: [ - video.url, - audio.url + video, + audio, ], filenameAttributes, - fileMetadata + fileMetadata, + isHLS: o.youtubeHLS, } } diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 51552d4c..4235d722 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -114,7 +114,7 @@ async function handleGenericStream(streamInfo, res) { } export function internalStream(streamInfo, res) { - if (streamInfo.service === 'youtube') { + if (streamInfo.service === 'youtube' && !streamInfo.isHLS) { return handleYoutubeStream(streamInfo, res); }