Files
YouTube.js/src/utils/FormatUtils.ts

571 lines
19 KiB
TypeScript

import Player from '../core/Player.js';
import Actions from '../core/Actions.js';
import type Format from '../parser/classes/misc/Format.js';
import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability.js';
import type { YTNode } from '../parser/helpers.js';
import { Constants } from './index.js';
import { getStringBetweenStrings, InnertubeError, Platform, streamToIterable } from './Utils.js';
export type URLTransformer = (url: URL) => URL;
export type FormatFilter = (format: Format) => boolean;
export interface FormatOptions {
/**
* Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
*/
quality?: string;
/**
* Download type, can be: video, audio or video+audio
*/
type?: 'video' | 'audio' | 'video+audio';
/**
* Language code, defaults to 'original'.
*/
language?: string;
/**
* File format, use 'any' to download any format
*/
format?: string;
/**
* InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED
*/
client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
}
export interface DownloadOptions extends FormatOptions {
/**
* Download range, indicates which bytes should be downloaded.
*/
range?: {
start: number;
end: number;
}
}
class FormatUtils {
static async download(options: DownloadOptions, actions: Actions, playability_status?: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: any;
}, streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}, player?: Player, cpn?: string): Promise<ReadableStream<Uint8Array>> {
if (playability_status?.status === 'UNPLAYABLE')
throw new InnertubeError('Video is unplayable', { error_type: 'UNPLAYABLE' });
if (playability_status?.status === 'LOGIN_REQUIRED')
throw new InnertubeError('Video is login required', { error_type: 'LOGIN_REQUIRED' });
if (!streaming_data)
throw new InnertubeError('Streaming data not available.', { error_type: 'NO_STREAMING_DATA' });
const opts: DownloadOptions = {
quality: '360p',
type: 'video+audio',
format: 'mp4',
range: undefined,
...options
};
const format = this.chooseFormat(opts, streaming_data);
const format_url = format.decipher(player);
// If we're not downloading the video in chunks, we just use fetch once.
if (opts.type === 'video+audio' && !options.range) {
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}`, {
method: 'GET',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
// Throw if the response is not 2xx
if (!response.ok)
throw new InnertubeError('The server responded with a non 2xx status code', { error_type: 'FETCH_FAILED', response });
const body = response.body;
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { error_type: 'FETCH_FAILED', response });
return body;
}
// We need to download in chunks.
const chunk_size = 1048576 * 10; // 10MB
let chunk_start = (options.range ? options.range.start : 0);
let chunk_end = (options.range ? options.range.end : chunk_size);
let must_end = false;
let cancel: AbortController;
const readable_stream = new Platform.shim.ReadableStream<Uint8Array>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
start() { },
pull: async (controller) => {
if (must_end) {
controller.close();
return;
}
if ((chunk_end >= format.content_length) || options.range) {
must_end = true;
}
return new Promise(async (resolve, reject) => {
try {
cancel = new AbortController();
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
method: 'GET',
headers: {
...Constants.STREAM_HEADERS
// XXX: use YouTube's range parameter instead of a Range header.
// Range: `bytes=${chunk_start}-${chunk_end}`
},
signal: cancel.signal
});
const body = response.body;
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response });
for await (const chunk of streamToIterable(body)) {
controller.enqueue(chunk);
}
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
resolve();
return;
} catch (e: any) {
reject(e);
}
});
},
async cancel(reason) {
cancel.abort(reason);
}
}, {
highWaterMark: 1, // TODO: better value?
size(chunk) {
return chunk.byteLength;
}
});
return readable_stream;
}
/**
* Selects the format that best matches the given options.
* @param options - Options
* @param streaming_data - Streaming data
*/
static chooseFormat(options: FormatOptions, streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}): Format {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
const formats = [
...(streaming_data.formats || []),
...(streaming_data.adaptive_formats || [])
];
const requires_audio = options.type ? options.type.includes('audio') : true;
const requires_video = options.type ? options.type.includes('video') : true;
const language = options.language || 'original';
const quality = options.quality || 'best';
let best_width = -1;
const is_best = [ 'best', 'bestefficiency' ].includes(quality);
const use_most_efficient = quality !== 'best';
let candidates = formats.filter((format) => {
if (requires_audio && !format.has_audio)
return false;
if (requires_video && !format.has_video)
return false;
if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
return false;
if (!is_best && format.quality_label !== quality)
return false;
if (best_width < format.width)
best_width = format.width;
return true;
});
if (!candidates.length)
throw new InnertubeError('No matching formats found', { options });
if (is_best && requires_video)
candidates = candidates.filter((format) => format.width === best_width);
if (requires_audio && !requires_video) {
const audio_only = candidates.filter((format) => {
if (language !== 'original') {
return !format.has_video && format.language === language;
}
return !format.has_video && format.is_original;
});
if (audio_only.length > 0) {
candidates = audio_only;
}
}
if (use_most_efficient) {
// Sort by bitrate (lower is better)
candidates.sort((a, b) => a.bitrate - b.bitrate);
} else {
// Sort by bitrate (higher is better)
candidates.sort((a, b) => b.bitrate - a.bitrate);
}
return candidates[0];
}
static async toDash(streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions): Promise<string> {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
let filtered_streaming_data;
if (format_filter) {
filtered_streaming_data = {
formats: streaming_data.formats.filter((fmt: Format) => !(format_filter(fmt))),
adaptive_formats: streaming_data.adaptive_formats.filter((fmt: Format) => !(format_filter(fmt))),
expires: streaming_data.expires,
dash_manifest_url: streaming_data.dash_manifest_url,
hls_manifest_url: streaming_data.hls_manifest_url
};
} else {
filtered_streaming_data = streaming_data;
}
const { adaptive_formats } = filtered_streaming_data;
if (!adaptive_formats.length)
throw new InnertubeError('No adaptive formats found');
const length = adaptive_formats[0].approx_duration_ms / 1000;
// DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
const document = new Platform.shim.DOMParser().parseFromString('<?xml version="1.0" encoding="utf-8"?><MPD />', 'application/xml');
const mpd = document.querySelector('MPD') as HTMLElement;
const period = document.createElement('Period');
mpd.replaceWith(this.#el(document, 'MPD', {
xmlns: 'urn:mpeg:dash:schema:mpd:2011',
minBufferTime: 'PT1.500S',
profiles: 'urn:mpeg:dash:profile:isoff-main:2011',
type: 'static',
mediaPresentationDuration: `PT${length}S`,
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'xsi:schemaLocation': 'urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd'
}, [
period
]));
await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions);
return Platform.shim.serializeDOM(document);
}
static #el(document: XMLDocument, tag: string, attrs: Record<string, string | undefined>, children: Node[] = []) {
const el = document.createElement(tag);
for (const [ key, value ] of Object.entries(attrs)) {
value && el.setAttribute(key, value);
}
for (const child of children) {
if (typeof child === 'undefined') continue;
el.appendChild(child);
}
return el;
}
static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const mime_types: string[] = [];
const mime_objects: Format[][] = [ [] ];
formats.forEach((video_format) => {
if ((!video_format.index_range || !video_format.init_range) && !video_format.is_type_otf) {
return;
}
const mime_type = video_format.mime_type;
const mime_type_index = mime_types.indexOf(mime_type);
if (mime_type_index > -1) {
mime_objects[mime_type_index].push(video_format);
} else {
mime_types.push(mime_type);
mime_objects.push([]);
mime_objects[mime_types.length - 1].push(video_format);
}
});
let set_id = 0;
for (let i = 0; i < mime_types.length; i++) {
// When the video has multiple different audio tracks we want to include the extra information in the manifest
if (mime_objects[i][0].has_audio && mime_objects[i][0].audio_track) {
const track_ids: string[] = [];
const track_objects: Format[][] = [ [] ];
mime_objects[i].forEach((format) => {
const id_index = track_ids.indexOf(format.audio_track?.id as string);
if (id_index > -1) {
track_objects[id_index].push(format);
} else {
track_ids.push(format.audio_track?.id as string);
track_objects.push([]);
track_objects[track_ids.length - 1].push(format);
}
});
// The lang attribute has to go on the AdaptationSet element and the Role element goes inside the AdaptationSet too, so we need a separate adaptation set for each language and role
for (let j = 0; j < track_ids.length; j++) {
const first_format = track_objects[j][0];
const children = [];
let role;
if (first_format.audio_track?.audio_is_default) {
role = 'main';
} else if (first_format.is_dubbed) {
role = 'dub';
} else if (first_format.is_descriptive) {
role = 'description';
} else {
role = 'alternate';
}
children.push(
this.#el(document, 'Role', {
schemeIdUri: 'urn:mpeg:dash:role:2011',
value: role
})
);
const set = this.#el(document, 'AdaptationSet', {
id: `${set_id++}`,
mimeType: mime_types[i].split(';')[0],
startWithSAP: '1',
subsegmentAlignment: 'true',
lang: first_format.language as string
}, children);
period.appendChild(set);
for (const format of track_objects[j]) {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
}
}
} else {
const set = this.#el(document, 'AdaptationSet', {
id: `${set_id++}`,
mimeType: mime_types[i].split(';')[0],
startWithSAP: '1',
subsegmentAlignment: 'true'
});
period.appendChild(set);
for (const format of mime_objects[i]) {
if (format.has_video) {
await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions);
} else {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
}
}
}
}
}
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const representation = this.#el(document, 'Representation', {
id: format.itag?.toString(),
codecs,
bandwidth: format.bitrate?.toString(),
width: format.width?.toString(),
height: format.height?.toString(),
maxPlayoutRate: '1',
frameRate: format.fps?.toString()
});
set.appendChild(representation);
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
let id;
if (format.audio_track) {
id = `${format.itag?.toString()}-${format.audio_track.id}`;
} else {
id = format.itag?.toString();
}
const representation = this.#el(document, 'Representation', {
id,
codecs,
bandwidth: format.bitrate?.toString(),
audioSamplingRate: format.audio_sample_rate?.toString()
}, [
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: format.audio_channels?.toString() || '2'
})
]);
set.appendChild(representation);
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) {
if (format.is_type_otf) {
if (!actions) {
throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format });
}
const { resolved_url, segment_durations } = await this.#getOTFSegmentInformation(url, actions);
const segment_elements = [];
for (const segment_duration of segment_durations) {
let attributes;
if (typeof segment_duration.repeat_count === 'undefined') {
attributes = {
d: segment_duration.duration.toString()
};
} else {
attributes = {
d: segment_duration.duration.toString(),
r: segment_duration.repeat_count.toString()
};
}
segment_elements.push(this.#el(document, 'S', attributes));
}
representation.appendChild(
this.#el(document, 'SegmentTemplate', {
startNumber: '1',
timescale: '1000',
initialization: `${resolved_url}&sq=0`,
media: `${resolved_url}&sq=$Number$`
}, [
this.#el(document, 'SegmentTimeline', {}, segment_elements)
])
);
} else {
if (!format.index_range || !format.init_range)
throw new InnertubeError('Index and init ranges not available', { format });
representation.appendChild(
this.#el(document, 'BaseURL', {}, [
document.createTextNode(url)
])
);
representation.appendChild(
this.#el(document, 'SegmentBase', {
indexRange: `${format.index_range.start}-${format.index_range.end}`
}, [
this.#el(document, 'Initialization', {
range: `${format.init_range.start}-${format.init_range.end}`
})
])
);
}
}
static async #getOTFSegmentInformation(url: string, actions: Actions): Promise<{
resolved_url: string,
segment_durations: {
duration: number,
repeat_count?: number
}[]
}> {
// Fetch the first segment as it contains the segment durations which we need to generate the manifest
const response = await actions.session.http.fetch_function(`${url}&rn=0&sq=0`, {
method: 'GET',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
// Example OTF video: https://www.youtube.com/watch?v=DJ8GQUNUXGM
// There might have been redirects, if there were we want to write the resolved URL to the manifest
// So that the player doesn't have to follow the redirects every time it requests a segment
const resolved_url = response.url.replace('&rn=0', '').replace('&sq=0', '');
// In this function we only need the segment durations and how often the durations are repeated
// The segment count could be useful for other stuff though
// The response body contains a lot of junk but the useful stuff looks like this:
// Segment-Count: 922\r\n' +
// 'Segment-Durations-Ms: 5120(r=920),3600,\r\n'
const response_text = await response.text();
const segment_duration_strings = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')?.split(',');
if (!segment_duration_strings) {
throw new InnertubeError('Failed to extract the segment durations from this OTF stream', { url });
}
const segment_durations = [];
for (const segment_duration_string of segment_duration_strings) {
const trimmed_segment_duration = segment_duration_string.trim();
if (trimmed_segment_duration.length === 0) {
continue;
}
let repeat_count;
const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')');
if (repeat_count_string) {
repeat_count = parseInt(repeat_count_string);
}
segment_durations.push({
duration: parseInt(trimmed_segment_duration),
repeat_count
});
}
return {
resolved_url,
segment_durations
};
}
}
export default FormatUtils;