Compare commits

..

3 Commits

Author SHA1 Message Date
github-actions[bot]
30ff087587 chore(main): release 5.6.0 (#448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-18 15:34:48 -03:00
absidue
1a034733f6 feat(toDash): Add option to include thumbnails in the manifest (#446)
* feat(toDash): Add option to include thumbnails in the manifest

* refactor: Move toDash function back to MediaInfo class
2023-07-18 02:08:02 -03:00
ChunkyProgrammer
c477b824c0 feat(parser): Add IncludingResultsFor (#447) 2023-07-18 01:53:44 -03:00
9 changed files with 198 additions and 9 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)
### Features
* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))
## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "5.5.0",
"version": "5.6.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "5.5.0",
"version": "5.6.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -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",

View File

@@ -7,6 +7,8 @@ import { InnertubeError } from '../../utils/Utils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { INextResponse, IPlayerResponse } from '../../parser/index.js';
import Parser from '../../parser/index.js';
import type { DashOptions } from '../../types/DashOptions.js';
import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';
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<string> {
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<string> {
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);
}
/**

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
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;
}
}

View File

@@ -13,7 +13,7 @@ export default class PlayerStoryboardSpec extends YTNode {
columns: number;
rows: number;
storyboard_count: number;
};
}[];
constructor(data: RawNode) {
super();

View File

@@ -126,6 +126,7 @@ export { default as HorizontalCardList } from './classes/HorizontalCardList.js';
export { default as HorizontalList } from './classes/HorizontalList.js';
export { default as HorizontalMovieList } from './classes/HorizontalMovieList.js';
export { default as IconLink } from './classes/IconLink.js';
export { default as IncludingResultsFor } from './classes/IncludingResultsFor.js';
export { default as InfoPanelContainer } from './classes/InfoPanelContainer.js';
export { default as InfoPanelContent } from './classes/InfoPanelContent.js';
export { default as InfoRow } from './classes/InfoRow.js';

9
src/types/DashOptions.ts Normal file
View File

@@ -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
}

View File

@@ -3,6 +3,7 @@ import type Actions from '../core/Actions.js';
import type Format from '../parser/classes/misc/Format.js';
import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability.js';
import type PlayerStoryboardSpec from '../parser/classes/PlayerStoryboardSpec.js';
import type { YTNode } from '../parser/helpers.js';
import * as Constants from './Constants.js';
@@ -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<string> {
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions, storyboards?: PlayerStoryboardSpec): Promise<string> {
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<Response>[] = [];
// 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) {