mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-14 01:52:11 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1886f9a83 | ||
|
|
5f4cbdb904 | ||
|
|
d91695a9ec | ||
|
|
137464ca66 | ||
|
|
6997982cf2 | ||
|
|
18cbc8c038 | ||
|
|
30ff087587 | ||
|
|
1a034733f6 | ||
|
|
c477b824c0 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [5.7.1](https://github.com/LuanRT/YouTube.js/compare/v5.7.0...v5.7.1) (2023-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **SearchHeader:** remove console.log ([d91695a](https://github.com/LuanRT/YouTube.js/commit/d91695a9ec6c55445cbeedba4ace4ac1e0a72eee))
|
||||
|
||||
## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
|
||||
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)
|
||||
|
||||
## [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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "5.5.0",
|
||||
"version": "5.7.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "5.5.0",
|
||||
"version": "5.7.1",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/LuanRT"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "5.5.0",
|
||||
"version": "5.7.1",
|
||||
"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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,26 +15,20 @@ export default class Button extends YTNode {
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
if (Reflect.has(data, 'text')) {
|
||||
if (Reflect.has(data, 'text'))
|
||||
this.text = new Text(data.text).toString();
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label')) {
|
||||
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label'))
|
||||
this.label = data.accessibility.label;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'tooltip')) {
|
||||
if (Reflect.has(data, 'tooltip'))
|
||||
this.tooltip = data.tooltip;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
|
||||
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType'))
|
||||
this.icon_type = data.icon.iconType;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'isDisabled')) {
|
||||
if (Reflect.has(data, 'isDisabled'))
|
||||
this.is_disabled = data.isDisabled;
|
||||
}
|
||||
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
|
||||
}
|
||||
|
||||
16
src/parser/classes/ContentPreviewImageView.ts
Normal file
16
src/parser/classes/ContentPreviewImageView.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
import Thumbnail from './misc/Thumbnail.js';
|
||||
|
||||
export default class ContentPreviewImageView extends YTNode {
|
||||
static type = 'ContentPreviewImageView';
|
||||
|
||||
image: Thumbnail[];
|
||||
style: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.image = data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width);
|
||||
this.style = data.style;
|
||||
}
|
||||
}
|
||||
13
src/parser/classes/DynamicTextView.ts
Normal file
13
src/parser/classes/DynamicTextView.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
|
||||
export default class DynamicTextView extends YTNode {
|
||||
static type = 'DynamicTextView';
|
||||
|
||||
text: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.text = data.text.content;
|
||||
}
|
||||
}
|
||||
25
src/parser/classes/IncludingResultsFor.ts
Normal file
25
src/parser/classes/IncludingResultsFor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { YTNode } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import type { IParsedResponse } from '../types/ParsedResponse.js';
|
||||
import CreatePlaylistDialog from './CreatePlaylistDialog.js';
|
||||
import OpenPopupAction from './actions/OpenPopupAction.js';
|
||||
|
||||
export default class NavigationEndpoint extends YTNode {
|
||||
static type = 'NavigationEndpoint';
|
||||
|
||||
payload;
|
||||
dialog?: CreatePlaylistDialog | YTNode | null;
|
||||
open_popup?: OpenPopupAction | null;
|
||||
|
||||
metadata: {
|
||||
url?: string;
|
||||
@@ -24,6 +26,9 @@ export default class NavigationEndpoint extends YTNode {
|
||||
if (Reflect.has(data || {}, 'innertubeCommand'))
|
||||
data = data.innertubeCommand;
|
||||
|
||||
if (Reflect.has(data || {}, 'openPopupAction'))
|
||||
this.open_popup = new OpenPopupAction(data.openPopupAction);
|
||||
|
||||
const name = Object.keys(data || {})
|
||||
.find((item) =>
|
||||
item.endsWith('Endpoint') ||
|
||||
@@ -36,6 +41,7 @@ export default class NavigationEndpoint extends YTNode {
|
||||
this.dialog = Parser.parseItem(this.payload.dialog || this.payload.content);
|
||||
}
|
||||
|
||||
|
||||
if (data?.serviceEndpoint) {
|
||||
data = data.serviceEndpoint;
|
||||
}
|
||||
@@ -85,9 +91,9 @@ export default class NavigationEndpoint extends YTNode {
|
||||
}
|
||||
}
|
||||
|
||||
call<T extends IParsedResponse>(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<T>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ApiResponse>;
|
||||
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
|
||||
call<T extends IParsedResponse>(actions: Actions, args: { [key: string]: any; parse: true }): Promise<T>;
|
||||
call(actions: Actions, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
|
||||
call(actions: Actions, args?: { [key: string]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
|
||||
if (!actions)
|
||||
throw new Error('An active caller must be provided');
|
||||
if (!this.metadata.api_url)
|
||||
|
||||
16
src/parser/classes/PageHeader.ts
Normal file
16
src/parser/classes/PageHeader.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import PageHeaderView from './PageHeaderView.js';
|
||||
|
||||
export default class PageHeader extends YTNode {
|
||||
static type = 'PageHeader';
|
||||
|
||||
page_title: string;
|
||||
content: PageHeaderView | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.page_title = data.pageTitle;
|
||||
this.content = Parser.parseItem(data.content, PageHeaderView);
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/PageHeaderView.ts
Normal file
17
src/parser/classes/PageHeaderView.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import ContentPreviewImageView from './ContentPreviewImageView.js';
|
||||
import DynamicTextView from './DynamicTextView.js';
|
||||
|
||||
export default class PageHeaderView extends YTNode {
|
||||
static type = 'PageHeaderView';
|
||||
|
||||
image: ContentPreviewImageView | null;
|
||||
title: DynamicTextView | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.image = Parser.parseItem(data.image, ContentPreviewImageView);
|
||||
this.title = Parser.parseItem(data.title, DynamicTextView);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default class PlayerStoryboardSpec extends YTNode {
|
||||
columns: number;
|
||||
rows: number;
|
||||
storyboard_count: number;
|
||||
};
|
||||
}[];
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
18
src/parser/classes/SearchFilterOptionsDialog.ts
Normal file
18
src/parser/classes/SearchFilterOptionsDialog.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import SearchFilterGroup from './SearchFilterGroup.js';
|
||||
import Text from './misc/Text.js';
|
||||
|
||||
export default class SearchFilterOptionsDialog extends YTNode {
|
||||
static type = 'SearchFilterOptionsDialog';
|
||||
|
||||
title: Text;
|
||||
groups: ObservedArray<SearchFilterGroup>;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/SearchHeader.ts
Normal file
17
src/parser/classes/SearchHeader.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import Button from './Button.js';
|
||||
import ChipCloud from './ChipCloud.js';
|
||||
|
||||
export default class SearchHeader extends YTNode {
|
||||
static type = 'SearchHeader';
|
||||
|
||||
chip_bar: ChipCloud | null;
|
||||
search_filter_button: Button | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.chip_bar = Parser.parseItem(data.chipBar, ChipCloud);
|
||||
this.search_filter_button = Parser.parseItem(data.searchFilterButton, Button);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ import ToggleButton from './ToggleButton.js';
|
||||
export default class SearchSubMenu extends YTNode {
|
||||
static type = 'SearchSubMenu';
|
||||
|
||||
title: Text;
|
||||
groups: ObservedArray<SearchFilterGroup>;
|
||||
button: ToggleButton | null;
|
||||
title?: Text;
|
||||
groups?: ObservedArray<SearchFilterGroup>;
|
||||
button?: ToggleButton | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
|
||||
this.button = Parser.parseItem(data.button, ToggleButton);
|
||||
if (Reflect.has(data, 'title'))
|
||||
this.title = new Text(data.title);
|
||||
|
||||
if (!Reflect.has(data, 'groups'))
|
||||
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
|
||||
|
||||
if (Reflect.has(data, 'button'))
|
||||
this.button = Parser.parseItem(data.button, ToggleButton);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export { default as CompactPlaylist } from './classes/CompactPlaylist.js';
|
||||
export { default as CompactStation } from './classes/CompactStation.js';
|
||||
export { default as CompactVideo } from './classes/CompactVideo.js';
|
||||
export { default as ConfirmDialog } from './classes/ConfirmDialog.js';
|
||||
export { default as ContentPreviewImageView } from './classes/ContentPreviewImageView.js';
|
||||
export { default as ContinuationItem } from './classes/ContinuationItem.js';
|
||||
export { default as ConversationBar } from './classes/ConversationBar.js';
|
||||
export { default as CopyLink } from './classes/CopyLink.js';
|
||||
@@ -81,6 +82,7 @@ export { default as DidYouMean } from './classes/DidYouMean.js';
|
||||
export { default as DownloadButton } from './classes/DownloadButton.js';
|
||||
export { default as Dropdown } from './classes/Dropdown.js';
|
||||
export { default as DropdownItem } from './classes/DropdownItem.js';
|
||||
export { default as DynamicTextView } from './classes/DynamicTextView.js';
|
||||
export { default as Element } from './classes/Element.js';
|
||||
export { default as EmergencyOnebox } from './classes/EmergencyOnebox.js';
|
||||
export { default as EmojiPickerCategory } from './classes/EmojiPickerCategory.js';
|
||||
@@ -126,6 +128,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';
|
||||
@@ -237,6 +240,8 @@ export { default as MusicTwoRowItem } from './classes/MusicTwoRowItem.js';
|
||||
export { default as MusicVisualHeader } from './classes/MusicVisualHeader.js';
|
||||
export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js';
|
||||
export { default as Notification } from './classes/Notification.js';
|
||||
export { default as PageHeader } from './classes/PageHeader.js';
|
||||
export { default as PageHeaderView } from './classes/PageHeaderView.js';
|
||||
export { default as PageIntroduction } from './classes/PageIntroduction.js';
|
||||
export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.js';
|
||||
export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.js';
|
||||
@@ -284,6 +289,8 @@ export { default as RichShelf } from './classes/RichShelf.js';
|
||||
export { default as SearchBox } from './classes/SearchBox.js';
|
||||
export { default as SearchFilter } from './classes/SearchFilter.js';
|
||||
export { default as SearchFilterGroup } from './classes/SearchFilterGroup.js';
|
||||
export { default as SearchFilterOptionsDialog } from './classes/SearchFilterOptionsDialog.js';
|
||||
export { default as SearchHeader } from './classes/SearchHeader.js';
|
||||
export { default as SearchRefinementCard } from './classes/SearchRefinementCard.js';
|
||||
export { default as SearchSubMenu } from './classes/SearchSubMenu.js';
|
||||
export { default as SearchSuggestion } from './classes/SearchSuggestion.js';
|
||||
|
||||
@@ -9,6 +9,7 @@ import SubscribeButton from '../classes/SubscribeButton.js';
|
||||
import ExpandableTab from '../classes/ExpandableTab.js';
|
||||
import SectionList from '../classes/SectionList.js';
|
||||
import Tab from '../classes/Tab.js';
|
||||
import PageHeader from '../classes/PageHeader.js';
|
||||
|
||||
import Feed from '../../core/mixins/Feed.js';
|
||||
import FilterableFeed from '../../core/mixins/FilterableFeed.js';
|
||||
@@ -25,7 +26,7 @@ import type { ApiResponse } from '../../core/Actions.js';
|
||||
import type { IBrowseResponse } from '../types/index.js';
|
||||
|
||||
export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader;
|
||||
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader;
|
||||
metadata;
|
||||
subscribe_button?: SubscribeButton;
|
||||
current_tab?: Tab | ExpandableTab;
|
||||
@@ -33,7 +34,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
|
||||
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader);
|
||||
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader, PageHeader);
|
||||
|
||||
const metadata = this.page.metadata?.item().as(ChannelMetadata);
|
||||
const microformat = this.page.microformat?.as(MicroformatData);
|
||||
|
||||
@@ -2,6 +2,7 @@ import Feed from '../../core/mixins/Feed.js';
|
||||
import { InnertubeError } from '../../utils/Utils.js';
|
||||
import HorizontalCardList from '../classes/HorizontalCardList.js';
|
||||
import ItemSection from '../classes/ItemSection.js';
|
||||
import SearchHeader from '../classes/SearchHeader.js';
|
||||
import SearchRefinementCard from '../classes/SearchRefinementCard.js';
|
||||
import SearchSubMenu from '../classes/SearchSubMenu.js';
|
||||
import SectionList from '../classes/SectionList.js';
|
||||
@@ -11,8 +12,8 @@ import type Actions from '../../core/Actions.js';
|
||||
import type { ApiResponse } from '../../core/Actions.js';
|
||||
import type { ObservedArray, YTNode } from '../helpers.js';
|
||||
import type { ISearchResponse } from '../types/ParsedResponse.js';
|
||||
|
||||
class Search extends Feed<ISearchResponse> {
|
||||
header?: SearchHeader;
|
||||
results?: ObservedArray<YTNode> | null;
|
||||
refinements: string[];
|
||||
estimated_results: number;
|
||||
@@ -30,6 +31,9 @@ class Search extends Feed<ISearchResponse> {
|
||||
if (!contents)
|
||||
throw new InnertubeError('No contents found in search response');
|
||||
|
||||
if (this.page.header)
|
||||
this.header = this.page.header.item().as(SearchHeader);
|
||||
|
||||
this.results = contents.find((content) => content.is(ItemSection) && content.contents && content.contents.length > 0)?.as(ItemSection).contents;
|
||||
|
||||
this.refinements = this.page.refinements || [];
|
||||
|
||||
9
src/types/DashOptions.ts
Normal file
9
src/types/DashOptions.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user