From a40abda80c4ce8bf27f3f62a41d9232f29984e0a Mon Sep 17 00:00:00 2001 From: LuanRT Date: Tue, 18 Jul 2023 18:35:51 +0000 Subject: [PATCH] chore: v5.6.0 release --- deno/package.json | 2 +- deno/src/core/mixins/MediaInfo.ts | 13 +- .../src/parser/classes/IncludingResultsFor.ts | 25 +++ .../parser/classes/PlayerStoryboardSpec.ts | 2 +- deno/src/parser/nodes.ts | 1 + deno/src/types/DashOptions.ts | 9 ++ deno/src/utils/FormatUtils.ts | 143 +++++++++++++++++- 7 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 deno/src/parser/classes/IncludingResultsFor.ts create mode 100644 deno/src/types/DashOptions.ts diff --git a/deno/package.json b/deno/package.json index 7d185149..33168a1e 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "5.5.0", + "version": "5.6.0", "description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).", "type": "module", "types": "./dist/src/platform/lib.d.ts", diff --git a/deno/src/core/mixins/MediaInfo.ts b/deno/src/core/mixins/MediaInfo.ts index 3edef240..990989b7 100644 --- a/deno/src/core/mixins/MediaInfo.ts +++ b/deno/src/core/mixins/MediaInfo.ts @@ -7,6 +7,8 @@ import { InnertubeError } from '../../utils/Utils.ts'; import type Format from '../../parser/classes/misc/Format.ts'; import type { INextResponse, IPlayerResponse } from '../../parser/index.ts'; import Parser from '../../parser/index.ts'; +import type { DashOptions } from '../../types/DashOptions.ts'; +import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.ts'; export default class MediaInfo { #page: [IPlayerResponse, INextResponse?]; @@ -37,10 +39,17 @@ export default class MediaInfo { * Generates a DASH manifest from the streaming data. * @param url_transformer - Function to transform the URLs. * @param format_filter - Function to filter the formats. + * @param options - Additional options to customise the manifest generation * @returns DASH manifest */ - async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise { - return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions); + async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise { + let storyboards; + + if (options.include_thumbnails && this.#page[0].storyboards?.is(PlayerStoryboardSpec)) { + storyboards = this.#page[0].storyboards; + } + + return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards); } /** diff --git a/deno/src/parser/classes/IncludingResultsFor.ts b/deno/src/parser/classes/IncludingResultsFor.ts new file mode 100644 index 00000000..796bf263 --- /dev/null +++ b/deno/src/parser/classes/IncludingResultsFor.ts @@ -0,0 +1,25 @@ +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import NavigationEndpoint from './NavigationEndpoint.ts'; +import Text from './misc/Text.ts'; + +export default class IncludingResultsFor extends YTNode { + static type = 'IncludingResultsFor'; + + including_results_for: Text; + corrected_query: Text; + corrected_query_endpoint: NavigationEndpoint; + search_only_for?: Text; + original_query?: Text; + original_query_endpoint?: NavigationEndpoint; + + constructor(data: RawNode) { + super(); + this.including_results_for = new Text(data.includingResultsFor); + this.corrected_query = new Text(data.correctedQuery); + this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint); + this.search_only_for = Reflect.has(data, 'searchOnlyFor') ? new Text(data.searchOnlyFor) : undefined; + this.original_query = Reflect.has(data, 'originalQuery') ? new Text(data.originalQuery) : undefined; + this.original_query_endpoint = Reflect.has(data, 'originalQueryEndpoint') ? new NavigationEndpoint(data.originalQueryEndpoint) : undefined; + } +} diff --git a/deno/src/parser/classes/PlayerStoryboardSpec.ts b/deno/src/parser/classes/PlayerStoryboardSpec.ts index 858658a2..13144876 100644 --- a/deno/src/parser/classes/PlayerStoryboardSpec.ts +++ b/deno/src/parser/classes/PlayerStoryboardSpec.ts @@ -13,7 +13,7 @@ export default class PlayerStoryboardSpec extends YTNode { columns: number; rows: number; storyboard_count: number; - }; + }[]; constructor(data: RawNode) { super(); diff --git a/deno/src/parser/nodes.ts b/deno/src/parser/nodes.ts index a2820d1c..54552670 100644 --- a/deno/src/parser/nodes.ts +++ b/deno/src/parser/nodes.ts @@ -126,6 +126,7 @@ export { default as HorizontalCardList } from './classes/HorizontalCardList.ts'; export { default as HorizontalList } from './classes/HorizontalList.ts'; export { default as HorizontalMovieList } from './classes/HorizontalMovieList.ts'; export { default as IconLink } from './classes/IconLink.ts'; +export { default as IncludingResultsFor } from './classes/IncludingResultsFor.ts'; export { default as InfoPanelContainer } from './classes/InfoPanelContainer.ts'; export { default as InfoPanelContent } from './classes/InfoPanelContent.ts'; export { default as InfoRow } from './classes/InfoRow.ts'; diff --git a/deno/src/types/DashOptions.ts b/deno/src/types/DashOptions.ts new file mode 100644 index 00000000..9ee78015 --- /dev/null +++ b/deno/src/types/DashOptions.ts @@ -0,0 +1,9 @@ +export interface DashOptions { + /** + * Include the storyboards in the DASH manifest when YouTube provides them. + * Not all players support parsing and displaying storyboards. + * If the player you are using doesn't support them you can leave this option disabled. + * They don't get included by default, as this requires making some extra requests while generating the manifest, which can slow down the manifest generation. + */ + include_thumbnails?: boolean +} diff --git a/deno/src/utils/FormatUtils.ts b/deno/src/utils/FormatUtils.ts index f0163eb8..3ea0c6f8 100644 --- a/deno/src/utils/FormatUtils.ts +++ b/deno/src/utils/FormatUtils.ts @@ -3,6 +3,7 @@ import type Actions from '../core/Actions.ts'; import type Format from '../parser/classes/misc/Format.ts'; import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability.ts'; +import type PlayerStoryboardSpec from '../parser/classes/PlayerStoryboardSpec.ts'; import type { YTNode } from '../parser/helpers.ts'; import * as Constants from './Constants.ts'; @@ -245,7 +246,7 @@ class FormatUtils { 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 { + }, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions, storyboards?: PlayerStoryboardSpec): Promise { if (!streaming_data) throw new InnertubeError('Streaming data not available'); @@ -280,7 +281,7 @@ class FormatUtils { period ])); - await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions); + await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions, storyboards); return Platform.shim.serializeDOM(document); } @@ -297,7 +298,16 @@ class FormatUtils { return el; } - static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) { + static async #generateAdaptationSet( + document: XMLDocument, + period: Element, + formats: Format[], + url_transformer: URLTransformer, + cpn?: string, + player?: Player, + actions?: Actions, + storyboards?: PlayerStoryboardSpec + ) { const mime_types: string[] = []; const mime_objects: Format[][] = [ [] ]; @@ -513,6 +523,64 @@ class FormatUtils { } } } + + // We need to make requests to get the image sizes, so we'll skip the storyboards if we don't have an Actions instance + if (storyboards && actions) { + const mime_types: string[] = []; + const mime_objects: { + template_url: string; + thumbnail_width: number; + thumbnail_height: number; + thumbnail_count: number; + interval: number; + columns: number; + rows: number; + storyboard_count: number; + }[][] = [ [] ]; + + for (const storyboard of storyboards.boards) { + const extension = new URL(storyboard.template_url).pathname.split('.').at(-1); + + let mime_type = ''; + + switch (extension) { + case 'jpg': + mime_type = 'image/jpeg'; + break; + case 'png': + mime_type = 'image/png'; + break; + case 'webp': + mime_type = 'image/webp'; + break; + } + + const mime_type_index = mime_types.indexOf(mime_type); + if (mime_type_index > -1) { + mime_objects[mime_type_index].push(storyboard); + } else { + mime_types.push(mime_type); + mime_objects.push([]); + mime_objects[mime_types.length - 1].push(storyboard); + } + } + + const duration = formats[0].approx_duration_ms / 1000; + + for (let i = 0; i < mime_types.length; i++) { + const set = this.#el(document, 'AdaptationSet', { + id: `${set_id++}`, + mimeType: mime_types[i], + contentType: 'image' + }); + + period.appendChild(set); + + for (const storyboard of mime_objects[i]) { + await this.#generateRepresentationImage(document, set, storyboard, duration, url_transformer, actions); + } + } + } } static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) { @@ -605,6 +673,75 @@ class FormatUtils { await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions); } + static async #generateRepresentationImage(document: XMLDocument, set: Element, storyboard: { + template_url: string; + thumbnail_width: number; + thumbnail_height: number; + thumbnail_count: number; + interval: number; + columns: number; + rows: number; + storyboard_count: number; + }, duration: number, url_transformer: URLTransformer, actions: Actions) { + const url = storyboard.template_url; + + const response_promises: Promise[] = []; + + // Set a limit so we don't take forever for long videos + const requestLimit = storyboard.storyboard_count > 10 ? 10 : storyboard.storyboard_count; + for (let i = 0; i < requestLimit; i++) { + const response_promise = actions.session.http.fetch_function(new URL(url.replace('$M', i.toString())), { + method: 'HEAD', + headers: Constants.STREAM_HEADERS + }); + + response_promises.push(response_promise); + } + + // Run the requests in parallel to avoid causing too much delay + const responses = await Promise.all(response_promises); + + const content_lengths = []; + + for (const response of responses) { + content_lengths.push(parseInt(response.headers.get('Content-Length') || '0', 10)); + + const content_type = response.headers.get('Content-Type'); + + // Sometimes youtube returns webp instead of jpg despite the file extension being jpg + // So we need to update the mime type to reflect the actual mime type of the response + + if (content_type && content_type.length > 0) { + if (set.getAttribute('mimeType') !== content_type) { + set.setAttribute('mimeType', content_type); + } + } + } + + // 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) / (storyboard.rows * storyboard.columns)) * 8); + + const representation = this.#el(document, 'Representation', { + id: `thumbnails_${storyboard.thumbnail_width}x${storyboard.thumbnail_height}`, + bandwidth: bandwidth.toString(), + width: (storyboard.thumbnail_width * storyboard.columns).toString(), + height: (storyboard.thumbnail_height * storyboard.rows).toString() + }, [ + this.#el(document, 'EssentialProperty', { + schemeIdUri: 'http://dashif.org/thumbnail_tile', + value: `${storyboard.columns}x${storyboard.rows}` + }), + this.#el(document, 'SegmentTemplate', { + media: url_transformer(new URL(url.replace('$M', '$Number$'))).toString(), + duration: (duration / storyboard.storyboard_count).toString(), + startNumber: '0' + }) + ]); + + set.appendChild(representation); + } + static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) { if (format.is_type_otf) { if (!actions) {