UNPKG

@np-dev/youtubei-js

Version:

A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).

353 lines 17.1 kB
import { __awaiter } from "tslib"; import { InnertubeError, Platform, getStringBetweenStrings } from './Utils.js'; import { Constants } from './index.js'; function getFormatGroupings(formats) { var _a, _b, _c; const group_info = new Map(); const has_multiple_audio_tracks = formats.some((fmt) => !!fmt.audio_track); for (const format of formats) { if ((!format.index_range || !format.init_range) && !format.is_type_otf) { continue; } const mime_type = format.mime_type.split(';')[0]; // Codec without any profile or level information const just_codec = (_a = getStringBetweenStrings(format.mime_type, 'codecs="', '"')) === null || _a === void 0 ? void 0 : _a.split('.')[0]; // HDR videos have both SDR and HDR vp9 formats, so we want to stick them in different groups const color_info = format.color_info ? Object.values(format.color_info).join('-') : ''; const audio_track_id = ((_b = format.audio_track) === null || _b === void 0 ? void 0 : _b.id) || ''; const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}`; if (!group_info.has(group_id)) { group_info.set(group_id, []); } (_c = group_info.get(group_id)) === null || _c === void 0 ? void 0 : _c.push(format); } return { groups: Array.from(group_info.values()), has_multiple_audio_tracks }; } function hoistCodecsIfPossible(formats, hoisted) { if (formats.length > 1 && new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1) { hoisted.push('codecs'); return getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"'); } } function hoistNumberAttributeIfPossible(formats, property, hoisted) { if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) { hoisted.push(property); return Number(formats[0][property]); } } function hoistAudioChannelsIfPossible(formats, hoisted) { if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels || 2)).size === 1) { hoisted.push('AudioChannelConfiguration'); return formats[0].audio_channels; } } function getOTFSegmentTemplate(url, actions) { var _a; return __awaiter(this, void 0, void 0, function* () { // Fetch the first segment as it contains the segment durations which we need to generate the manifest const response = yield 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 = yield response.text(); const segment_duration_strings = (_a = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')) === null || _a === void 0 ? void 0 : _a.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 { init_url: `${resolved_url}&sq=0`, media_url: `${resolved_url}&sq=$Number$`, timeline: segment_durations }; }); } function getSegmentInfo(format, url_transformer, actions, player, cpn) { const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); const transformed_url = url_transformer(url).toString(); 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 info = { is_oft: true, getSegmentTemplate() { return getOTFSegmentTemplate(transformed_url, actions); } }; return info; } if (!format.index_range || !format.init_range) throw new InnertubeError('Index and init ranges not available', { format }); const info = { is_oft: false, base_url: transformed_url, index_range: format.index_range, init_range: format.init_range }; return info; } function getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn) { const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); const rep = { uid: format.audio_track ? `${format.itag}-${format.audio_track.id}` : format.itag.toString(), bitrate: format.bitrate, codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined, audio_sample_rate: !hoisted.includes('audio_sample_rate') ? format.audio_sample_rate : undefined, channels: !hoisted.includes('AudioChannelConfiguration') ? format.audio_channels || 2 : undefined, segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn) }; return rep; } function getTrackRole(format) { const { audio_track } = format; if (!audio_track) return; if (audio_track.audio_is_default) return 'main'; if (format.is_dubbed) return 'dub'; if (format.is_descriptive) return 'description'; return 'alternate'; } function getAudioSet(formats, url_transformer, actions, player, cpn) { var _a; const first_format = formats[0]; const { audio_track } = first_format; const hoisted = []; const set = { mime_type: first_format.mime_type.split(';')[0], language: (_a = first_format.language) !== null && _a !== void 0 ? _a : undefined, codecs: hoistCodecsIfPossible(formats, hoisted), audio_sample_rate: hoistNumberAttributeIfPossible(formats, 'audio_sample_rate', hoisted), track_name: audio_track === null || audio_track === void 0 ? void 0 : audio_track.display_name, track_role: getTrackRole(first_format), channels: hoistAudioChannelsIfPossible(formats, hoisted), representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn)) }; return set; } const COLOR_PRIMARIES = { BT709: '1', BT2020: '9' }; const COLOR_TRANSFER_CHARACTERISTICS = { BT709: '1', BT2020_10: '14', SMPTEST2084: '16', ARIB_STD_B67: '18' }; // This list is incomplete, as the player.js doesn't currently have any code for matrix coefficients, // So it doesn't have a list like with the other two, so this is just based on what we've seen in responses const COLOR_MATRIX_COEFFICIENTS = { BT709: '1', BT2020_NCL: '14' }; function getColorInfo(format) { // Section 5.5 Video source metadata signalling https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part7-v5.0.0.pdf // Section 8 Video code points https://www.itu.int/rec/T-REC-H.273-202107-I/en // The player.js file was also helpful const color_info = format.color_info; const primaries = (color_info === null || color_info === void 0 ? void 0 : color_info.primaries) ? COLOR_PRIMARIES[color_info.primaries] : undefined; const transfer_characteristics = (color_info === null || color_info === void 0 ? void 0 : color_info.transfer_characteristics) ? COLOR_TRANSFER_CHARACTERISTICS[color_info.transfer_characteristics] : undefined; const matrix_coefficients = (color_info === null || color_info === void 0 ? void 0 : color_info.matrix_coefficients) ? COLOR_MATRIX_COEFFICIENTS[color_info.matrix_coefficients] : undefined; if ((color_info === null || color_info === void 0 ? void 0 : color_info.matrix_coefficients) && !matrix_coefficients) { const url = new URL(format.url); const anonymisedFormat = JSON.parse(JSON.stringify(format)); anonymisedFormat.url = 'REDACTED'; anonymisedFormat.signature_cipher = 'REDACTED'; anonymisedFormat.cipher = 'REDACTED'; console.warn(`YouTube.js toDash(): Unknown matrix coefficients "${color_info.matrix_coefficients}", the DASH manifest is still usuable without this.\n` + `Please report it at ${Platform.shim.info.bugs_url} so we can add support for it.\n` + `Innertube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat); } const info = { primaries, transfer_characteristics, matrix_coefficients }; return info; } function getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn) { const rep = { uid: format.itag.toString(), bitrate: format.bitrate, width: format.width, height: format.height, codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined, fps: !hoisted.includes('fps') ? format.fps : undefined, segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn) }; return rep; } function getVideoSet(formats, url_transformer, player, actions, cpn) { const first_format = formats[0]; const color_info = getColorInfo(first_format); const hoisted = []; const set = { mime_type: first_format.mime_type.split(';')[0], color_info, codecs: hoistCodecsIfPossible(formats, hoisted), fps: hoistNumberAttributeIfPossible(formats, 'fps', hoisted), representations: formats.map((format) => getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn)) }; return set; } function getStoryboardInfo(storyboards) { var _a; const mime_info = new Map(); for (const storyboard of storyboards.boards) { const extension = new URL(storyboard.template_url).pathname.split('.').pop(); const mime_type = `image/${extension === 'jpg' ? 'jpeg' : extension}`; if (!mime_info.has(mime_type)) { mime_info.set(mime_type, []); } (_a = mime_info.get(mime_type)) === null || _a === void 0 ? void 0 : _a.push(storyboard); } return mime_info; } function getStoryboardMimeType(actions, board, transform_url, probable_mime_type, shared_response) { return __awaiter(this, void 0, void 0, function* () { const url = board.template_url; const req_url = transform_url(new URL(url.replace('$M', '0'))); const res_promise = shared_response.response ? shared_response.response : actions.session.http.fetch_function(req_url, { method: 'HEAD', headers: Constants.STREAM_HEADERS }); shared_response.response = res_promise; const res = yield res_promise; return res.headers.get('Content-Type') || probable_mime_type; }); } function getStoryboardBitrate(actions, board, shared_response) { return __awaiter(this, void 0, void 0, function* () { const url = board.template_url; const response_promises = []; // Set a limit so we don't take forever for long videos const request_limit = Math.min(board.storyboard_count, 10); for (let i = 0; i < request_limit; i++) { const req_url = new URL(url.replace('$M', i.toString())); const response_promise = i === 0 && shared_response.response ? shared_response.response : actions.session.http.fetch_function(req_url, { method: 'HEAD', headers: Constants.STREAM_HEADERS }); if (i === 0) shared_response.response = response_promise; response_promises.push(response_promise); } // Run the requests in parallel to avoid causing too much delay const responses = yield Promise.all(response_promises); const content_lengths = []; for (const response of responses) { content_lengths.push(parseInt(response.headers.get('Content-Length') || '0')); } // This is a rough estimate, so it probably won't reflect that actual peak bitrate // Hopefully it's close enough, because figuring out the actual peak bitrate would require downloading and analysing all storyboard tiles const bandwidth = Math.ceil((Math.max(...content_lengths) / (board.rows * board.columns)) * 8); return bandwidth; }); } function getImageRepresentation(duration, actions, board, transform_url, shared_response) { const url = board.template_url; const template_url = new URL(url.replace('$M', '$Number$')); const rep = { uid: `thumbnails_${board.thumbnail_width}x${board.thumbnail_height}`, getBitrate() { return getStoryboardBitrate(actions, board, shared_response); }, sheet_width: board.thumbnail_width * board.columns, sheet_height: board.thumbnail_height * board.rows, thumbnail_height: board.thumbnail_height, thumbnail_width: board.thumbnail_width, rows: board.rows, columns: board.columns, template_duration: duration / board.storyboard_count, template_url: transform_url(template_url).toString(), getURL(n) { return template_url.toString().replace('$Number$', n.toString()); } }; return rep; } function getImageSets(duration, actions, storyboards, transform_url) { const mime_info = getStoryboardInfo(storyboards); const shared_response = {}; return Array.from(mime_info.entries()).map(([type, boards]) => ({ probable_mime_type: type, getMimeType() { return getStoryboardMimeType(actions, boards[0], transform_url, type, shared_response); }, representations: boards.map((board) => getImageRepresentation(duration, actions, board, transform_url, shared_response)) })); } export function getStreamingInfo(streaming_data, url_transformer = (url) => url, format_filter, cpn, player, actions, storyboards) { if (!streaming_data) throw new InnertubeError('Streaming data not available'); const formats = format_filter ? streaming_data.adaptive_formats.filter((fmt) => !format_filter(fmt)) : streaming_data.adaptive_formats; const duration = formats[0].approx_duration_ms / 1000; const { groups, has_multiple_audio_tracks } = getFormatGroupings(formats); const { video_groups, audio_groups } = groups.reduce((acc, formats) => { if (formats[0].has_audio) { // Some videos with multiple audio tracks, have a broken one, that doesn't have any audio track information // It seems to be the same as default audio track but broken // We want to ignore it, as it messes up audio track selection in players and YouTube ignores it too // At the time of writing, this video has a broken audio track: https://youtu.be/UJeSWbR6W04 if (has_multiple_audio_tracks && !formats[0].audio_track) return acc; acc.audio_groups.push(formats); return acc; } acc.video_groups.push(formats); return acc; }, { video_groups: [], audio_groups: [] }); const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn)); const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn)); // XXX: We need to make requests to get the image sizes, so we'll skip the storyboards if we don't have an Actions instance const image_sets = storyboards && actions ? getImageSets(duration, actions, storyboards, url_transformer) : []; const info = { duration, audio_sets, video_sets, image_sets }; return info; } //# sourceMappingURL=StreamingInfo.js.map