Skip to content

Instantly share code, notes, and snippets.

@erdesigns-eu
Created December 31, 2024 16:31
Show Gist options
  • Save erdesigns-eu/1811c9a4038b28cd0d39b6207c329384 to your computer and use it in GitHub Desktop.
Save erdesigns-eu/1811c9a4038b28cd0d39b6207c329384 to your computer and use it in GitHub Desktop.
import fetch from 'node-fetch';
/**
* Youtube Resolver Parameters
* @interface YoutubeResolverParams
* @property {string} url - Youtube URL (https://www.youtube.com/watch?v=VIDEO_ID)
* @property {string} videoId - Youtube Video ID
*/
interface YoutubeResolverParams {
url?: string;
videoId?: string;
}
/**
* Youtube HLS Subtitle interface.
* @interface Subtitle
* @property {string} url - URL to the subtitle stream
* @property {string} language - Display name of the subtitle language
* @property {string} languageCode - ISO language code
*/
interface YoutubeHlsSubtitle {
url: string;
language: string;
languageCode: string;
}
/**
* Youtube Video Stream Quality enum.
* @enum {string} - Video stream quality
*/
enum YoutubeVideoStreamQuality {
'2160p' = '2160p',
'1440p' = '1440p',
'1080p' = '1080p',
'720p' = '720p',
'480p' = '480p',
'360p' = '360p',
'240p' = '240p',
'144p' = '144p',
}
/**
* Youtube Audio Stream Quality enum.
* @enum {string} - Audio stream quality
*/
enum YoutubeAudioStreamQuality {
'high' = 'high',
'medium' = 'medium',
'low' = 'low',
}
/**
* Youtube stream interface.
* @interface YoutubeStream
* @property {string} url - URL to the stream
* @property {number} width - Width of the video resolution
* @property {number} height - Height of the video resolution
* @property {number} fps - Frames per second
* @property {number} bitrate - Bitrate of the stream
* @property {number} channels - Number of audio channels
* @property {number} sampleRate - Audio sample rate
* @property {string} mimeType - MIME type of the stream
* @property {string[]} codecs - Array of codec strings used in the stream
* @property {YoutubeVideoStreamQuality} videoQuality - Video stream quality
* @property {YoutubeAudioStreamQuality} audioQuality - Audio stream quality
*/
interface YoutubeStream {
url: string;
width?: number;
height?: number;
fps?: number;
bitrate?: number;
channels?: number;
sampleRate?: number;
mimeType?: string;
codecs?: string[];
videoQuality?: YoutubeVideoStreamQuality;
audioQuality?: YoutubeAudioStreamQuality;
}
/**
* Youtube HLS stream interface.
* @interface YoutubeHlsStream
* @property {string} video - URL to the video stream
* @property {string} audio - URL to the associated audio stream
* @property {Subtitle[]} subtitles - Array of associated subtitle streams
* @property {number} width - Width of the video resolution
* @property {number} height - Height of the video resolution
* @property {number} fps - Frames per second
* @property {string[]} codecs - Array of codec strings used in the stream
*/
interface YoutubeHlsStream {
video: string;
audio: string;
subtitles: YoutubeHlsSubtitle[];
width: number;
height: number;
fps: number;
codecs: string[];
videoQuality?: YoutubeVideoStreamQuality;
}
/**
* Youtube stream type.
* @type {YoutubeStreamType}
*/
type YoutubeStreamType = YoutubeStream | YoutubeHlsStream;
/**
* Filter stream options.
* @type {FiltertreamOptions} - Filter stream options
* @property {boolean} hasAudio - Filter streams with audio
* @property {boolean} hasVideo - Filter streams with video
* @property {YoutubeVideoStreamQuality} videoQuality - Filter streams by video quality
* @property {YoutubeAudioStreamQuality} audioQuality - Filter streams by audio quality
* @property {boolean} isHLS - Filter HLS streams
*/
type FiltertreamOptions = {
hasAudio?: boolean;
hasVideo?: boolean;
videoQuality?: YoutubeVideoStreamQuality;
audioQuality?: YoutubeAudioStreamQuality;
isHLS?: boolean;
}
/**
* Parses Youtube M3U8 files to extract audio, video, and subtitle streams.
* @class YoutubeM3U8Parser
*/
class YoutubeM3U8Parser {
private audioMap: Map<string, string> = new Map();
private subtitlesMap: Map<string, YoutubeHlsSubtitle[]> = new Map();
private streams: YoutubeHlsStream[] = [];
/**
* Parses the M3U8 content and extracts streams with associated audio and subtitles.
* @param content - The M3U8 file content as a string.
* @returns An array of streams, each containing video and its associated audio and subtitles.
*/
public parse(content: string): YoutubeHlsStream[] {
// Split the content into lines
const lines = content.split(/\r?\n/);
// Initialize the line index
let i = 0;
// Loop through all lines in the content
while (i < lines.length) {
// Trim the line to remove whitespace
const line = lines[i].trim();
// Process Media tags (audio and subtitles)
if (line.startsWith('#EXT-X-MEDIA')) {
const attributes = this.parseAttributes(line);
this.processMedia(attributes);
}
// Process Stream tags (video)
else if (line.startsWith('#EXT-X-STREAM-INF')) {
const attributes = this.parseAttributes(line);
const uriLine = lines[++i]?.trim() || '';
this.processStream(attributes, uriLine);
}
// Move to the next line
i++;
}
// Return the list of streams
return this.streams;
}
/**
* Processes a media tag and stores audio or subtitles accordingly.
* @param attributes - Parsed attributes from the media tag.
*/
private processMedia(attributes: { [key: string]: string }): void {
// Get the type from the attributes
const type = attributes['TYPE'];
// Get the group ID from the attributes
const groupId = attributes['GROUP-ID'];
// Get the URI from the attributes
const uri = attributes['URI'];
// Check if any of the required attributes are missing
if (!type || !groupId || !uri) {
console.warn('Media tag missing required attributes:', attributes);
return;
}
// If the type is AUDIO, store the audio URL
if (type === 'AUDIO') {
this.audioMap.set(groupId, uri);
}
// If the type is SUBTITLES, store the subtitle URL
else if (type === 'SUBTITLES') {
// Get the language from the attributes
const language = attributes['NAME'] || 'Unknown';
// Get the language code from the attributes
const languageCode = attributes['LANGUAGE'] || '';
// Create a new subtitle object with the extracted data
const subtitle: YoutubeHlsSubtitle = { language, languageCode, url: uri };
// Check if the subtitles map already has the group ID
if (!this.subtitlesMap.has(groupId)) {
this.subtitlesMap.set(groupId, []);
}
// Add the subtitle to the list of subtitles
this.subtitlesMap.get(groupId)?.push(subtitle);
}
}
/**
* Processes a stream tag and associates it with the corresponding audio and subtitles.
* @param attributes - Parsed attributes from the stream tag.
* @param uri - URL to the video stream.
*/
private processStream(attributes: { [key: string]: string }, uri: string): void {
// Get the audio group ID (if available)
const audioGroupId = attributes['AUDIO'];
// Get the subtitles group ID (if available)
const subtitlesGroupId = attributes['SUBTITLES'];
// Parse the resolution attribute into width and height
const resolution = this.parseResolution(attributes['RESOLUTION'] || '0x0');
// Get the codecs used in the stream
const codecs = attributes['CODECS'].split(',') || [];
// Get the frame rate from the attributes
const frameRate = parseInt(attributes['FRAME-RATE'] || '0', 10);
// Get the audio URL from the audio group ID
const audioUrl = audioGroupId ? this.audioMap.get(audioGroupId) || '' : '';
if (audioGroupId && !audioUrl) {
console.warn(`No audio stream found for GROUP-ID: ${audioGroupId}`);
}
// Get the subtitles from the subtitles group ID
const subtitles = subtitlesGroupId ? this.subtitlesMap.get(subtitlesGroupId) || [] : [];
// Create a new stream object with the extracted data
const stream: YoutubeHlsStream = {
video: uri,
audio: audioUrl,
subtitles: subtitles,
width: resolution.width,
height: resolution.height,
fps: frameRate,
codecs: codecs,
};
// Add the stream to the list of streams
this.streams.push(stream);
}
/**
* Parses resolution string into width and height.
* @param resolutionStr Resolution string in the format 'widthxheight'.
* @returns An object containing width and height as numbers.
*/
private parseResolution(resolutionStr: string): { width: number; height: number } {
// Split the resolution string into width and height
const [width, height] = resolutionStr.split('x').map((num) => parseInt(num, 10));
// Return an object with width and height as numbers
return { width, height };
}
/**
* Parses attributes from a tag line.
* @param line The tag line containing attributes.
* @returns An object with key-value pairs of attributes.
*/
private parseAttributes(line: string): { [key: string]: string } {
// Create an empty object to store the attributes
const result: { [key: string]: string } = {};
// Find the first colon in the line
const firstColon = line.indexOf(':');
// If no colon is found, return the empty object
if (firstColon === -1) {
return result;
}
// Get the attributes string after the colon
const attrsString = line.substring(firstColon + 1);
// Create a regex to match key-value pairs
const regex = /([A-Z0-9\-]+)=("([^"]*)"|[^,]*)/g;
// Initialize a match variable
let match: RegExpExecArray | null;
// Loop through all matches and extract key-value pairs
while ((match = regex.exec(attrsString)) !== null) {
const key = match[1];
const value = match[3] !== undefined ? match[3] : match[2];
result[key] = value;
}
// Return the object with extracted attributes
return result;
}
}
/**
* Youtube Resolver class to resolve video information and streaming data from the Youtube API.
* @class YoutubeResolver
*/
class YoutubeResolver {
private readonly url: string = 'https://youtubei.googleapis.com/youtubei/v1/player?prettyPrint=false';
private readonly clientName: string = 'IOS';
private readonly clientVersion: string = '19.42.1';
private _videoId: string | null;
private _title: string = '';
private _author: string = '';
private _keywords: string[] = [];
private _duration: number = 0;
private _description: string = '';
private _views: number = 0;
private _thumbnails: string[] = [];
/*
isCrawlable
isLiveContent
isOwnerViewing
isPrivate
isUnpluggedCorpus
*/
private _streams: YoutubeStream[] = [];
private _hlsStreams: YoutubeHlsStream[] = [];
/**
* Create a new Youtube Resolver instance
* @param {YoutubeResolverParams} params - The Youtube Resolver Parameters
* @returns {Promise<YoutubeResolver>} The Youtube Resolver instance
*/
static async create(params: YoutubeResolverParams): Promise<YoutubeResolver> {
// Create a new Youtube Resolver instance
const resolver = new YoutubeResolver(params);
// Resolve the video from the Youtube API
await resolver.resolveVideo();
// Return the Youtube Resolver instance with resolved video data
return resolver;
}
/**
* Youtube Constructor
* @private This constructor is private so it can only be called from the static resolve method.
* @param {YoutubeResolverParams} params - The Youtube Resolver Parameters
*/
private constructor(params: YoutubeResolverParams) {
// Make sure the required parameters are provided
if (!params.url && !params.videoId) {
throw new Error('Missing required parameter: url or videoId');
}
// Set the video ID from the provided parameters
if (params.videoId) {
this._videoId = params.videoId;
}
else if (params.url) {
this._videoId = this.getVideoId(params.url);
}
else {
this._videoId = null;
}
// Validate the video ID
if (this._videoId === null) {
throw new Error('Invalid Youtube URL or Video ID');
}
}
/**
* Get the Youtube Video ID from a URL
* @param url - The URL of the Youtube video
* @returns The Youtube Video ID or null if not found
*/
private getVideoId(url: string): string | null {
if (/youtu\.?be/.test(url)) {
// Known patterns for YouTube video IDs
const patterns: RegExp[] = [
/youtu\.be\/([^#\&\?]{11})/, // youtu.be/<id>
/\?v=([^#\&\?]{11})/, // ?v=<id>
/\&v=([^#\&\?]{11})/, // &v=<id>
/embed\/([^#\&\?]{11})/, // embed/<id>
/\/v\/([^#\&\?]{11})/ // /v/<id>
];
// Check patterns for a match and return the ID if found
for (let i = 0; i < patterns.length; i++) {
const match = patterns[i].exec(url);
if (match) {
return match[1];
}
}
// Fallback: Split URL into tokens and look for an ID
const tokens = url.split(/[\/\&\?=#\.\s]/g);
for (let token of tokens) {
if (/^[^#\&\?]{11}$/.test(token)) {
return token;
}
}
}
return null;
}
/**
* Extract the codecs from a MIME type string (e.g. video/mp4; codecs="avc1.42001E, mp4a.40.2")
* @param mimeType - The MIME type string
* @returns The codecs as a string or array of strings
*/
private extractCodecs(mimeType: string): string[] {
const codecs: string[] = [];
// Regular expression to match codecs within quotes
const regex = /codecs\s*=\s*"([^"]+)"/i;
const match = mimeType.match(regex);
// If a match is found, split the codecs by comma and trim whitespace
if (match && match[1]) {
codecs.push(...match[1].split(',').map((codec) => codec.trim()));
}
// Return the list of codecs
return codecs;
}
/**
* Get the video quality based on the stream resolution
* @param stream - The video stream object
* @returns The video quality
*/
private getVideoQuality(stream: any): YoutubeVideoStreamQuality {
if (stream.height) {
if (stream.height >= 2160 || stream.qualityLabel === '2160p') {
return YoutubeVideoStreamQuality['2160p'];
}
if (stream.height >= 1440 || stream.qualityLabel === '1440p') {
return YoutubeVideoStreamQuality['1440p'];
}
if (stream.height >= 1080 || stream.qualityLabel === '1080p') {
return YoutubeVideoStreamQuality['1080p'];
}
if (stream.height >= 720 || stream.qualityLabel === '720p') {
return YoutubeVideoStreamQuality['720p'];
}
if (stream.height >= 480 || stream.qualityLabel === '480p') {
return YoutubeVideoStreamQuality['480p'];
}
if (stream.height >= 360 || stream.qualityLabel === '360p') {
return YoutubeVideoStreamQuality['360p'];
}
if (stream.height >= 240 || stream.qualityLabel === '240p') {
return YoutubeVideoStreamQuality['240p'];
}
if (stream.height >= 144 || stream.qualityLabel === '144p') {
return YoutubeVideoStreamQuality['144p'];
}
}
return YoutubeVideoStreamQuality['144p'];
}
/**
* Get the audio quality based on the stream audio quality label
* @param stream - The audio stream object
* @returns The audio quality
*/
private getAudioQuality(stream: any): YoutubeAudioStreamQuality {
if (stream.audioQuality) {
if (stream.audioQuality === 'AUDIO_QUALITY_HIGH') {
return YoutubeAudioStreamQuality.high;
}
if (stream.audioQuality === 'AUDIO_QUALITY_MEDIUM') {
return YoutubeAudioStreamQuality.medium;
}
if (stream.audioQuality === 'AUDIO_QUALITY_LOW') {
return YoutubeAudioStreamQuality.low;
}
}
return YoutubeAudioStreamQuality.low;
}
/**
* Sort the video streams by video quality
* @param streams - The list of video streams
* @returns The sorted list of video streams
*/
private sortVideoStreams(streams: YoutubeStreamType[]): YoutubeStreamType[] {
return streams.sort((a, b) => {
// Sort by video quality if available
if (a.videoQuality && b.videoQuality) {
return Object.values(YoutubeVideoStreamQuality).indexOf(a.videoQuality) - Object.values(YoutubeVideoStreamQuality).indexOf(b.videoQuality);
}
// If only stream a has video quality, prioritize it
else if (a.videoQuality) {
return -1;
}
// If only stream b has video quality, prioritize it
else if (b.videoQuality) {
return 1;
}
// Keep the order unchanged if no video quality is available
return 0;
});
}
/**
* Sort the audio streams by audio quality
* @param streams - The list of audio streams
* @returns The sorted list of audio streams
*/
private sortAudioStreams(streams: YoutubeStream[]): YoutubeStream[] {
return streams.sort((a, b) => {
// Sort by audio quality if available
if (a.audioQuality && b.audioQuality) {
return Object.values(YoutubeAudioStreamQuality).indexOf(a.audioQuality) - Object.values(YoutubeAudioStreamQuality).indexOf(b.audioQuality);
}
// If only stream a has audio quality, prioritize it
else if (a.audioQuality) {
return -1;
}
// If only stream b has audio quality, prioritize it
else if (b.audioQuality) {
return 1;
}
// Keep the order unchanged if no audio quality is available
return 0;
});
}
/**
* Sort the streams by video and audio quality
* @param streams - The list of streams
* @returns The sorted list of streams
*/
private sortStreams(streams: YoutubeStreamType[]): YoutubeStreamType[] {
// Separate the streams into video and audio streams
const videoStreams = streams.filter((stream) => stream.width && stream.height);
const audioStreams = streams.filter((stream) => (!stream.width || !stream.height)) as YoutubeStream[];
// Sort the video streams by video quality
const sortedVideoStreams = this.sortVideoStreams(videoStreams);
// Sort the audio streams by audio quality
const sortedAudioStreams = this.sortAudioStreams(audioStreams);
// Combine the sorted video and audio streams
return [...sortedVideoStreams, ...sortedAudioStreams];
}
/**
* Resolve the video info and streaming data from the Youtube API
* @returns void
*/
private async resolveVideo(): Promise<void> {
// Fetch the video info from the Youtube API (non-public: innerTube API)
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
videoId: this._videoId,
context: {
client: {
clientName: this.clientName,
clientVersion: this.clientVersion
}
}
})
});
// Validate the response
if (!response.ok) {
throw new Error(`Failed to resolve video info: ${response.statusText}`);
}
// Parse the response JSON
const data = await response.json() as any;
// Get the playability status from the response
const playabilityStatus = data.playabilityStatus;
if (!playabilityStatus || playabilityStatus.status !== 'OK') {
throw new Error(`Video is not playable: ${playabilityStatus.reason}`);
}
// Get the video details from the response
const videoDetails = data.videoDetails;
// Get the streaming data from the response
const streamingData = data.streamingData;
// Validate the video details
if (!videoDetails) {
throw new Error('Failed to resolve video details');
}
// Validate the streaming data
if (!streamingData) {
throw new Error('Failed to resolve streaming data');
}
// Set the video details
this._title = videoDetails.title;
this._author = videoDetails.author;
this._keywords = videoDetails.keywords;
this._duration = parseInt(videoDetails.lengthSeconds, 10);
this._description = videoDetails.shortDescription;
this._views = parseInt(videoDetails.viewCount, 10);
this._thumbnails = [
`https://i.ytimg.com/vi/${this.videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`,
`https://i.ytimg.com/vi/${this.videoId}/sddefault.jpg`,
`https://i.ytimg.com/vi_webp/${this.videoId}/maxresdefault.webp`,
`https://i.ytimg.com/vi_webp/${this.videoId}/hqdefault.webp`,
`https://i.ytimg.com/vi_webp/${this.videoId}/sddefault.webp`,
];
// If there is a hlsManifestUrl, resolve the HLS streams
if (streamingData.hlsManifestUrl) {
// Fetch the HLS manifest from the URL
const hlsResponse = await fetch(streamingData.hlsManifestUrl);
// Get the content of the HLS manifest as text
const hlsContent = await hlsResponse.text();
// Create a new M3U8 parser instance
const m3u8Parser = new YoutubeM3U8Parser();
// Parse the HLS content and extract the streams with audio and subtitles
this._hlsStreams = m3u8Parser.parse(hlsContent);
// Set the video quality for each HLS stream
this._hlsStreams.forEach((stream) => {
stream.videoQuality = this.getVideoQuality(stream);
});
}
// Combine the formats and adaptiveFormats into a single list
const formats = [
...streamingData.formats || [],
...streamingData.adaptiveFormats || []
];
// Validate the streaming formats
if (formats.length === 0) {
throw new Error('No streaming formats found');
}
// Resolve the streams from the streaming data
formats.forEach((format: any) => {
// Extract the stream data from the format
const { url, width, height, fps, bitrate, audioChannels, audioSampleRate, mimeType } = format;
// Add the stream to the list of streams
this._streams.push({
url,
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
fps: fps ? parseInt(fps, 10) : undefined,
bitrate: bitrate ? parseInt(bitrate, 10) : undefined,
channels: audioChannels ? parseInt(audioChannels, 10) : undefined,
sampleRate: audioSampleRate ? parseInt(audioSampleRate, 10) : undefined,
mimeType,
codecs: mimeType ? this.extractCodecs(mimeType) : undefined,
videoQuality: width && height ? this.getVideoQuality(format) : undefined,
audioQuality: audioSampleRate ? this.getAudioQuality(format) : undefined,
})
});
}
/**
* Filter the streams based on the provided options
* @param options - The options for filtering streams
* @returns The list of streams that match the provided options
*/
public filterStreams(options: FiltertreamOptions): YoutubeStreamType[] {
// Extract the options for filtering streams
const { hasAudio, hasVideo, videoQuality, audioQuality, isHLS } = options;
// Array to store the filtered streams
let streams = [];
// If isHLS is true, we only need to consider HLS streams
if (isHLS) {
streams = [...this._hlsStreams];
}
// Otherwise, consider all streams
else {
streams = [...this._streams];
}
// Filter streams that have audio (non-HLS)
if (hasAudio && !isHLS) {
streams = streams.filter((stream) => (stream as YoutubeStream).audioQuality);
}
// Filter streams that have video (non-HLS)
if (hasVideo && !isHLS) {
streams = streams.filter((stream) => stream.videoQuality);
}
// Filter streams by video quality
if (videoQuality) {
streams = streams.filter((stream) => stream.videoQuality === videoQuality);
}
// Filter streams by audio quality
if (audioQuality) {
streams = streams.filter((stream) => (stream as YoutubeStream).audioQuality === audioQuality);
}
// Sort the streams by video and audio quality
return this.sortStreams(streams);
}
/**
* Get the Youtube Video ID
* @returns The Youtube Video ID
*/
public get videoId(): string {
return this._videoId!;
}
/**
* Get the video title
* @returns The video title
*/
public get title(): string {
return this._title;
}
/**
* Get the video author
* @returns The video author
*/
public get author(): string {
return this._author;
}
/**
* Get the video keywords
* @returns The video keywords
*/
public get keywords(): string[] {
return this._keywords;
}
/**
* Get the video duration in seconds
* @returns The video duration in seconds
*/
public get duration(): number {
return this._duration;
}
/**
* Get the video description
* @returns The video description
*/
public get description(): string {
return this._description;
}
/**
* Get the video views count
* @returns The video views count
*/
public get views(): number {
return this._views;
}
/**
* Get the thumbnail URLs
* @returns The video thumbnails
*/
public get thumbnails(): string[] {
return this._thumbnails;
}
/**
* Get the video streams (non-HLS)
* @returns The video streams
*/
public get streams(): YoutubeStream[] {
return this.sortStreams(this._streams) as YoutubeStream[];
}
/**
* Get the video HLS streams
* @returns The video HLS streams
*/
public get hlsStreams(): YoutubeHlsStream[] {
return this.sortVideoStreams(this._hlsStreams) as YoutubeHlsStream[];
}
/**
* Youtube Resolver static properties
* @property {YoutubeVideoStreamQuality} videoStreamQuality - Video stream quality enum
* @property {YoutubeAudioStreamQuality} audioStreamQuality - Audio stream quality enum
*/
static videoStreamQuality = YoutubeVideoStreamQuality;
static audioStreamQuality = YoutubeAudioStreamQuality;
}
export default YoutubeResolver;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment