Compare commits

..

8 Commits

Author SHA1 Message Date
LuanRT
55d47e1fe6 chore: v5.7.0 release 2023-07-24 23:49:17 +00:00
LuanRT
a40abda80c chore: v5.6.0 release 2023-07-18 18:35:51 +00:00
LuanRT
e147a01ba5 chore: v5.5.0 release 2023-07-16 20:58:15 +00:00
LuanRT
499937fe3a chore: v5.4.0 release 2023-07-14 03:01:47 +00:00
LuanRT
0582bcb677 chore: v5.3.0 release 2023-07-11 18:49:53 +00:00
LuanRT
50fdef7c07 chore: v5.2.1 release 2023-07-04 01:31:31 +00:00
LuanRT
5f69f10e85 chore: v5.2.0 release 2023-06-28 20:14:40 +00:00
LuanRT
dc69b7437e chore: v5.1.0 release 2023-05-14 04:13:24 +00:00
49 changed files with 1069 additions and 131 deletions

View File

@@ -542,6 +542,8 @@ Retrieves contents for a given channel.
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getReleases()`
- `<channel>#getPodcasts()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "5.0.4",
"version": "5.7.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",
@@ -84,7 +84,7 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.0.0",
"jintr": "^1.1.0",
"linkedom": "^0.14.12",
"tslib": "^2.5.0",
"undici": "^5.19.1"

View File

@@ -96,7 +96,7 @@ export default class OAuth {
client_id: this.#identity.client_id,
scope: Constants.OAUTH.SCOPE,
device_id: Platform.shim.uuidv4(),
model_name: Constants.OAUTH.MODEL_NAME
device_model: Constants.OAUTH.MODEL_NAME
};
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {

View File

@@ -8,6 +8,11 @@ export const PATH = '/player';
* @returns The payload.
*/
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
const is_android =
opts.client === 'ANDROID' ||
opts.client === 'YTMUSIC_ANDROID' ||
opts.client === 'YTSTUDIO_ANDROID';
return {
playbackContext: {
contentPlaybackContext: {
@@ -37,8 +42,8 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
...{
client: opts.client,
playlistId: opts.playlist_id,
// Workaround streaming URLs returning 403 when using Android clients and throttling in web clients.
params: '8AEB'
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
params: is_android ? '2AMBCgIQBg' : opts.params
}
};
}

View File

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

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

View File

@@ -0,0 +1,17 @@
import { type ObservedArray, YTNode } from '../helpers.ts';
import InfoRow from './InfoRow.ts';
import Parser, { type RawNode } from '../index.ts';
import CompactVideo from './CompactVideo.ts';
export default class CarouselLockup extends YTNode {
static type = 'CarouselLockup';
info_rows: ObservedArray<InfoRow>;
video_lockup?: CompactVideo | null;
constructor(data: RawNode) {
super();
this.info_rows = Parser.parseArray(data.infoRows, InfoRow);
this.video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
}
}

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Thumbnail from './misc/Thumbnail.ts';
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;
}
}

View File

@@ -0,0 +1,13 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
export default class DynamicTextView extends YTNode {
static type = 'DynamicTextView';
text: string;
constructor(data: RawNode) {
super();
this.text = data.text.content;
}
}

View File

@@ -0,0 +1,26 @@
import { YTNode } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import ContinuationItem from './ContinuationItem.ts';
import EngagementPanelTitleHeader from './EngagementPanelTitleHeader.ts';
import MacroMarkersList from './MacroMarkersList.ts';
import SectionList from './SectionList.ts';
import StructuredDescriptionContent from './StructuredDescriptionContent.ts';
export default class EngagementPanelSectionList extends YTNode {
static type = 'EngagementPanelSectionList';
header: EngagementPanelTitleHeader | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | MacroMarkersList | null;
target_id?: string;
panel_identifier?: string;
visibility?: string;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent, MacroMarkersList ]);
this.panel_identifier = data.panelIdentifier;
this.target_id = data.targetId;
this.visibility = data.visibility;
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Text from './misc/Text.ts';
import Button from './Button.ts';
export default class EngagementPanelTitleHeader extends YTNode {
static type = 'EngagementPanelTitleHeader';
title: Text;
visibility_button: Button | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.visibility_button = Parser.parseItem(data.visibilityButton, Button);
}
}

View File

@@ -0,0 +1,22 @@
import { YTNode } from '../helpers.ts';
import { Text } from '../misc.ts';
import type { RawNode } from '../index.ts';
export default class ExpandableVideoDescriptionBody extends YTNode {
static type = 'ExpandableVideoDescriptionBody';
show_more_text: Text;
show_less_text: Text;
attributed_description_body_text?: string;
constructor(data: RawNode) {
super();
this.show_more_text = new Text(data.showMoreText);
this.show_less_text = new Text(data.showLessText);
if (Reflect.has(data, 'attributedDescriptionBodyText')) {
this.attributed_description_body_text = data.attributedDescriptionBodyText?.content;
}
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
import { type RawNode } from '../index.ts';
import { Text } from '../misc.ts';
export default class Factoid extends YTNode {
static type = 'Factoid';
label: Text;
value: Text;
accessibility_text: String;
constructor(data: RawNode) {
super();
this.label = new Text(data.label);
this.value = new Text(data.value);
this.accessibility_text = data.accessibilityText;
}
}

View File

@@ -0,0 +1,28 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import { Thumbnail } from '../misc.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import Text from './misc/Text.ts';
export default class HashtagTile extends YTNode {
static type = 'HashtagTile';
hashtag: Text;
hashtag_info_text: Text;
hashtag_thumbnail: Thumbnail[];
endpoint: NavigationEndpoint;
hashtag_background_color: number;
hashtag_video_count: Text;
hashtag_channel_count: Text;
constructor(data: RawNode) {
super();
this.hashtag = new Text(data.hashtag);
this.hashtag_info_text = new Text(data.hashtagInfoText);
this.hashtag_thumbnail = Thumbnail.fromResponse(data.hashtagThumbnail);
this.endpoint = new NavigationEndpoint(data.onTapCommand);
this.hashtag_background_color = data.hashtagBackgroundColor;
this.hashtag_video_count = new Text(data.hashtagVideoCount);
this.hashtag_channel_count = new Text(data.hashtagChannelCount);
}
}

View File

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

View File

@@ -0,0 +1,29 @@
import { YTNode } from '../helpers.ts';
import { Text } from '../misc.ts';
import type { RawNode } from '../index.ts';
export default class InfoRow extends YTNode {
static type = 'InfoRow';
title: Text;
default_metadata?: Text;
expanded_metadata?: Text;
info_row_expand_status_key?: String;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
if (Reflect.has(data, 'defaultMetadata')) {
this.default_metadata = new Text(data.defaultMetadata);
}
if (Reflect.has(data, 'expandedMetadata')) {
this.expanded_metadata = new Text(data.expandedMetadata);
}
if (Reflect.has(data, 'infoRowExpandStatusKey')) {
this.info_row_expand_status_key = data.infoRowExpandStatusKey;
}
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Menu from './menus/Menu.ts';
import Text from './misc/Text.ts';
export default class MacroMarkersInfoItem extends YTNode {
static type = 'MacroMarkersInfoItem';
info_text: Text;
menu: Menu | null;
constructor(data: RawNode) {
super();
this.info_text = new Text(data.infoText);
this.menu = Parser.parseItem(data.menu, Menu);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import { Text } from '../misc.ts';
import MacroMarkersInfoItem from './MacroMarkersInfoItem.ts';
import MacroMarkersListItem from './MacroMarkersListItem.ts';
export default class MacroMarkersList extends YTNode {
static type = 'MacroMarkersList';
contents: ObservedArray<MacroMarkersInfoItem | MacroMarkersListItem>;
sync_button_label: Text;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents, [ MacroMarkersInfoItem, MacroMarkersListItem ]);
this.sync_button_label = new Text(data.syncButtonLabel);
}
}

View File

@@ -4,12 +4,14 @@ import { YTNode } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import type { IParsedResponse } from '../types/ParsedResponse.ts';
import CreatePlaylistDialog from './CreatePlaylistDialog.ts';
import OpenPopupAction from './actions/OpenPopupAction.ts';
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)

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import PageHeaderView from './PageHeaderView.ts';
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);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import ContentPreviewImageView from './ContentPreviewImageView.ts';
import DynamicTextView from './DynamicTextView.ts';
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);
}
}

View File

@@ -17,10 +17,10 @@ export default class PlayerCaptionsTracklist extends YTNode {
audio_tracks?: {
audio_track_id: string;
captions_initial_state: string;
default_caption_track_index: number;
default_caption_track_index?: number;
has_default_track: boolean;
visibility: string;
caption_track_indices: number;
caption_track_indices: number[];
}[];
default_audio_track_index?: number;

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

@@ -1,6 +1,8 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
import PlaylistCustomThumbnail from './PlaylistCustomThumbnail.ts';
import PlaylistVideoThumbnail from './PlaylistVideoThumbnail.ts';
import Author from './misc/Author.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
@@ -12,6 +14,7 @@ export default class Playlist extends YTNode {
title: Text;
author: Text | Author;
thumbnails: Thumbnail[];
thumbnail_renderer?: PlaylistVideoThumbnail | PlaylistCustomThumbnail;
video_count: Text;
video_count_short: Text;
first_videos: ObservedArray<YTNode>;
@@ -41,6 +44,10 @@ export default class Playlist extends YTNode {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
if (Reflect.has(data, 'thumbnailRenderer')) {
this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, [ PlaylistVideoThumbnail, PlaylistCustomThumbnail ]) || undefined;
}
if (Reflect.has(data, 'viewPlaylistText')) {
this.view_playlist = new Text(data.viewPlaylistText);
}

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
import Text from './misc/Text.ts';
export default class Quiz extends YTNode {
static type = 'Quiz';
choices: {
text: Text;
is_correct: boolean;
}[];
total_votes: Text;
constructor(data: RawNode) {
super();
this.choices = data.choices.map((choice: RawNode) => ({
text: new Text(choice.text),
is_correct: choice.isCorrect
}));
this.total_votes = new Text(data.totalVotes);
}
}

View File

@@ -12,6 +12,7 @@ export default class ReelItem extends YTNode {
thumbnails: Thumbnail[];
views: Text;
endpoint: NavigationEndpoint;
accessibility_label?: string;
constructor(data: RawNode) {
super();
@@ -20,5 +21,6 @@ export default class ReelItem extends YTNode {
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.views = new Text(data.viewCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.accessibility_label = data.accessibility.accessibilityData.label;
}
}

View File

@@ -0,0 +1,18 @@
import type { ObservedArray } from '../helpers.ts';
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import SearchFilterGroup from './SearchFilterGroup.ts';
import Text from './misc/Text.ts';
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);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import Button from './Button.ts';
import ChipCloud from './ChipCloud.ts';
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);
console.log(this.search_filter_button?.endpoint.open_popup);
}
}

View File

@@ -8,14 +8,19 @@ import ToggleButton from './ToggleButton.ts';
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);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import ExpandableVideoDescriptionBody from './ExpandableVideoDescriptionBody.ts';
import HorizontalCardList from './HorizontalCardList.ts';
import VideoDescriptionHeader from './VideoDescriptionHeader.ts';
import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.ts';
import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.ts';
export default class StructuredDescriptionContent extends YTNode {
static type = 'StructuredDescriptionContent';
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection | HorizontalCardList>;
constructor(data: RawNode) {
super();
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection, HorizontalCardList ]);
}
}

View File

@@ -0,0 +1,28 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import { Text, Thumbnail } from '../misc.ts';
import Factoid from './Factoid.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
export default class VideoDescriptionHeader extends YTNode {
static type = 'VideoDescriptionHeader';
channel: Text;
channel_navigation_endpoint?: NavigationEndpoint;
channel_thumbnail: Thumbnail[];
factoids: ObservedArray<Factoid>;
publish_date: Text;
title: Text;
views: Text;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.channel = new Text(data.channel);
this.channel_navigation_endpoint = new NavigationEndpoint(data.channelNavigationEndpoint);
this.channel_thumbnail = Thumbnail.fromResponse(data.channelThumbnail);
this.publish_date = new Text(data.publishDate);
this.views = new Text(data.views);
this.factoids = Parser.parseArray(data.factoid, Factoid);
}
}

View File

@@ -0,0 +1,28 @@
import Parser, { type RawNode } from '../index.ts';
import { YTNode } from '../helpers.ts';
import Text from './misc/Text.ts';
import Thumbnail from './misc/Thumbnail.ts';
import Button from './Button.ts';
import NavigationEndpoint from './NavigationEndpoint.ts';
export default class VideoDescriptionInfocardsSection extends YTNode {
static type = 'VideoDescriptionInfocardsSection';
section_title: Text;
creator_videos_button: Button | null;
creator_about_button: Button | null;
section_subtitle: Text;
channel_avatar: Thumbnail[];
channel_endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.section_title = new Text(data.sectionTitle);
this.creator_videos_button = Parser.parseItem(data.creatorVideosButton, Button);
this.creator_about_button = Parser.parseItem(data.creatorAboutButton, Button);
this.section_subtitle = new Text(data.sectionSubtitle);
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
this.channel_endpoint = new NavigationEndpoint(data.channelEndpoint);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import Parser, { type RawNode } from '../index.ts';
import { Text } from '../misc.ts';
import CarouselLockup from './CarouselLockup.ts';
export default class VideoDescriptionMusicSection extends YTNode {
static type = 'VideoDescriptionMusicSection';
carousel_lockups: ObservedArray<CarouselLockup>;
section_title: Text;
constructor(data: RawNode) {
super();
this.carousel_lockups = Parser.parseArray(data.carouselLockups, CarouselLockup);
this.section_title = new Text(data.sectionTitle);
}
}

View File

@@ -1,9 +1,10 @@
import Text from '../misc/Text.ts';
import Thumbnail from '../misc/Thumbnail.ts';
import CommentsSimplebox from './CommentsSimplebox.ts';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.ts';
import { YTNode } from '../../helpers.ts';
import type { RawNode } from '../../index.ts';
import { Parser } from '../../index.ts';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.ts';
export default class CommentsEntryPointHeader extends YTNode {
static type = 'CommentsEntryPointHeader';
@@ -12,7 +13,7 @@ export default class CommentsEntryPointHeader extends YTNode {
comment_count?: Text;
teaser_avatar?: Thumbnail[];
teaser_content?: Text;
content_renderer?: CommentsEntryPointTeaser | null;
content_renderer?: CommentsEntryPointTeaser | CommentsSimplebox | null;
simplebox_placeholder?: Text;
constructor(data: RawNode) {
@@ -35,7 +36,7 @@ export default class CommentsEntryPointHeader extends YTNode {
}
if (Reflect.has(data, 'contentRenderer')) {
this.content_renderer = Parser.parseItem(data.contentRenderer, CommentsEntryPointTeaser);
this.content_renderer = Parser.parseItem(data.contentRenderer, [ CommentsEntryPointTeaser, CommentsSimplebox ]);
}
if (Reflect.has(data, 'simpleboxPlaceholder')) {

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../../helpers.ts';
import Text from '../misc/Text.ts';
import Thumbnail from '../misc/Thumbnail.ts';
import type { RawNode } from '../../index.ts';
export default class CommentsSimplebox extends YTNode {
static type = 'CommentsSimplebox';
simplebox_avatar: Thumbnail[];
simplebox_placeholder: Text;
constructor(data: RawNode) {
super();
this.simplebox_avatar = Thumbnail.fromResponse(data.simpleboxAvatar);
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
}
}

View File

@@ -46,6 +46,12 @@ export default class Format {
is_descriptive?: boolean;
is_original?: boolean;
color_info?: {
primaries?: string;
transfer_characteristics?: string;
matrix_coefficients?: string;
};
constructor(data: RawNode) {
this.itag = data.itag;
this.mime_type = data.mimeType;
@@ -81,14 +87,24 @@ export default class Format {
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
this.has_video = !!data.qualityLabel;
this.color_info = data.colorInfo ? {
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
} : undefined;
if (this.has_audio) {
const args = new URLSearchParams(this.cipher || this.signature_cipher);
const url_components = new URLSearchParams(args.get('url') || this.url);
this.language = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('lang='))?.split('=').at(1) || null;
this.is_dubbed = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'dubbed';
this.is_descriptive = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'descriptive';
this.is_original = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'original' || !this.is_dubbed;
const xtags = url_components.get('xtags')?.split(':');
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;
this.is_dubbed = audio_content === 'dubbed';
this.is_descriptive = audio_content === 'descriptive';
this.is_original = audio_content === 'original' || !this.is_dubbed || !this.is_descriptive;
if (Reflect.has(data, 'audioTrack')) {
this.audio_track = {

View File

@@ -18,6 +18,7 @@ export default class VideoDetails {
is_live_content: boolean;
is_upcoming: boolean;
is_crawlable: boolean;
is_post_live_dvr: boolean;
constructor(data: RawNode) {
this.id = data.videoId;
@@ -35,6 +36,7 @@ export default class VideoDetails {
this.is_live = !!data.isLive;
this.is_live_content = !!data.isLiveContent;
this.is_upcoming = !!data.isUpcoming;
this.is_post_live_dvr = !!data.isPostLiveDvr;
this.is_crawlable = !!data.isCrawlable;
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-cond-assign */
import { Platform } from '../utils/Utils.ts';
import { InnertubeError, Platform } from '../utils/Utils.ts';
import Author from './classes/misc/Author.ts';
import Text from './classes/misc/Text.ts';
import Thumbnail from './classes/misc/Thumbnail.ts';
@@ -62,7 +62,9 @@ export class YTNodeGenerator {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
static #logNewClass(classname: string, key_info: KeyInfo) {
console.warn(`${classname} not found!\nThis is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!\nIntrospected and JIT generated this class in the meantime:\n${this.generateTypescriptClass(classname, key_info)}`);
console.warn(
new InnertubeError(`${classname} not found!\nThis is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!\nIntrospected and JIT generated this class in the meantime:\n${this.generateTypescriptClass(classname, key_info)}`)
);
}
static #logChangedKeys(classname: string, key_info: KeyInfo, changed_keys: KeyInfo) {
console.warn(`${classname} changed!\nThe following keys where altered: ${changed_keys.map(([ key ]) => this.#camelToSnake(key)).join(', ')}\nThe class has changed to:\n${this.generateTypescriptClass(classname, key_info)}`);
@@ -460,15 +462,15 @@ export class YTNodeGenerator {
* @returns The parsed value
*/
static parse(key: string, inference_type: InferenceType, data: any, key_path: string[] = [ 'data' ]) {
const should_optional = !inference_type.optional || this.#hasDataFromKeyPath({data}, [ ...key_path, key ]);
const should_optional = !inference_type.optional || this.#hasDataFromKeyPath({ data }, [ ...key_path, key ]);
switch (inference_type.type) {
case 'renderer':
{
return should_optional ? Parser.parseItem(this.#accessDataFromKeyPath({data}, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
return should_optional ? Parser.parseItem(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
}
case 'renderer_list':
{
return should_optional ? Parser.parse(this.#accessDataFromKeyPath({data}, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
return should_optional ? Parser.parse(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
}
case 'object':
{
@@ -482,25 +484,25 @@ export class YTNodeGenerator {
case 'misc':
switch (inference_type.misc_type) {
case 'NavigationEndpoint':
return should_optional ? new NavigationEndpoint(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? new NavigationEndpoint(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Text':
return should_optional ? new Text(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? new Text(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Thumbnail':
return should_optional ? Thumbnail.fromResponse(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? Thumbnail.fromResponse(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Author':
{
const author_should_optional = !inference_type.optional || this.#hasDataFromKeyPath({data}, [ ...key_path, inference_type.params[0] ]);
const author_should_optional = !inference_type.optional || this.#hasDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]);
return author_should_optional ? new Author(
this.#accessDataFromKeyPath({data}, [ ...key_path, inference_type.params[0] ]),
this.#accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]),
inference_type.params[1] ?
this.#accessDataFromKeyPath({data}, [ ...key_path, inference_type.params[1] ]) : undefined
this.#accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[1] ]) : undefined
) : undefined;
}
}
throw new Error('Unreachable code reached! Switch missing case!');
case 'primative':
case 'unknown':
return this.#accessDataFromKeyPath({data}, [ ...key_path, key ]);
return this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]);
}
}
static #passOne(classdata: any) {

View File

@@ -30,6 +30,7 @@ export { default as Card } from './classes/Card.ts';
export { default as CardCollection } from './classes/CardCollection.ts';
export { default as CarouselHeader } from './classes/CarouselHeader.ts';
export { default as CarouselItem } from './classes/CarouselItem.ts';
export { default as CarouselLockup } from './classes/CarouselLockup.ts';
export { default as Channel } from './classes/Channel.ts';
export { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullMetadata.ts';
export { default as ChannelAgeGate } from './classes/ChannelAgeGate.ts';
@@ -57,6 +58,7 @@ export { default as CommentsEntryPointHeader } from './classes/comments/Comments
export { default as CommentsEntryPointTeaser } from './classes/comments/CommentsEntryPointTeaser.ts';
export { default as CommentsHeader } from './classes/comments/CommentsHeader.ts';
export { default as CommentSimplebox } from './classes/comments/CommentSimplebox.ts';
export { default as CommentsSimplebox } from './classes/comments/CommentsSimplebox.ts';
export { default as CommentThread } from './classes/comments/CommentThread.ts';
export { default as CreatorHeart } from './classes/comments/CreatorHeart.ts';
export { default as EmojiPicker } from './classes/comments/EmojiPicker.ts';
@@ -69,6 +71,7 @@ export { default as CompactPlaylist } from './classes/CompactPlaylist.ts';
export { default as CompactStation } from './classes/CompactStation.ts';
export { default as CompactVideo } from './classes/CompactVideo.ts';
export { default as ConfirmDialog } from './classes/ConfirmDialog.ts';
export { default as ContentPreviewImageView } from './classes/ContentPreviewImageView.ts';
export { default as ContinuationItem } from './classes/ContinuationItem.ts';
export { default as ConversationBar } from './classes/ConversationBar.ts';
export { default as CopyLink } from './classes/CopyLink.ts';
@@ -79,6 +82,7 @@ export { default as DidYouMean } from './classes/DidYouMean.ts';
export { default as DownloadButton } from './classes/DownloadButton.ts';
export { default as Dropdown } from './classes/Dropdown.ts';
export { default as DropdownItem } from './classes/DropdownItem.ts';
export { default as DynamicTextView } from './classes/DynamicTextView.ts';
export { default as Element } from './classes/Element.ts';
export { default as EmergencyOnebox } from './classes/EmergencyOnebox.ts';
export { default as EmojiPickerCategory } from './classes/EmojiPickerCategory.ts';
@@ -88,9 +92,13 @@ export { default as Endscreen } from './classes/Endscreen.ts';
export { default as EndscreenElement } from './classes/EndscreenElement.ts';
export { default as EndScreenPlaylist } from './classes/EndScreenPlaylist.ts';
export { default as EndScreenVideo } from './classes/EndScreenVideo.ts';
export { default as EngagementPanelSectionList } from './classes/EngagementPanelSectionList.ts';
export { default as EngagementPanelTitleHeader } from './classes/EngagementPanelTitleHeader.ts';
export { default as ExpandableMetadata } from './classes/ExpandableMetadata.ts';
export { default as ExpandableTab } from './classes/ExpandableTab.ts';
export { default as ExpandableVideoDescriptionBody } from './classes/ExpandableVideoDescriptionBody.ts';
export { default as ExpandedShelfContents } from './classes/ExpandedShelfContents.ts';
export { default as Factoid } from './classes/Factoid.ts';
export { default as FeedFilterChipBar } from './classes/FeedFilterChipBar.ts';
export { default as FeedTabbedHeader } from './classes/FeedTabbedHeader.ts';
export { default as GameCard } from './classes/GameCard.ts';
@@ -110,6 +118,7 @@ export { default as GuideEntry } from './classes/GuideEntry.ts';
export { default as GuideSection } from './classes/GuideSection.ts';
export { default as GuideSubscriptionsSection } from './classes/GuideSubscriptionsSection.ts';
export { default as HashtagHeader } from './classes/HashtagHeader.ts';
export { default as HashtagTile } from './classes/HashtagTile.ts';
export { default as Heatmap } from './classes/Heatmap.ts';
export { default as HeatMarker } from './classes/HeatMarker.ts';
export { default as HeroPlaylistThumbnail } from './classes/HeroPlaylistThumbnail.ts';
@@ -119,8 +128,10 @@ 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';
export { default as InteractiveTabbedHeader } from './classes/InteractiveTabbedHeader.ts';
export { default as ItemSection } from './classes/ItemSection.ts';
export { default as ItemSectionHeader } from './classes/ItemSectionHeader.ts';
@@ -172,6 +183,8 @@ export { default as LiveChatItemList } from './classes/LiveChatItemList.ts';
export { default as LiveChatMessageInput } from './classes/LiveChatMessageInput.ts';
export { default as LiveChatParticipant } from './classes/LiveChatParticipant.ts';
export { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList.ts';
export { default as MacroMarkersInfoItem } from './classes/MacroMarkersInfoItem.ts';
export { default as MacroMarkersList } from './classes/MacroMarkersList.ts';
export { default as MacroMarkersListItem } from './classes/MacroMarkersListItem.ts';
export { default as Menu } from './classes/menus/Menu.ts';
export { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem.ts';
@@ -227,6 +240,8 @@ export { default as MusicTwoRowItem } from './classes/MusicTwoRowItem.ts';
export { default as MusicVisualHeader } from './classes/MusicVisualHeader.ts';
export { default as NavigationEndpoint } from './classes/NavigationEndpoint.ts';
export { default as Notification } from './classes/Notification.ts';
export { default as PageHeader } from './classes/PageHeader.ts';
export { default as PageHeaderView } from './classes/PageHeaderView.ts';
export { default as PageIntroduction } from './classes/PageIntroduction.ts';
export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.ts';
export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.ts';
@@ -259,6 +274,7 @@ export { default as ProfileColumn } from './classes/ProfileColumn.ts';
export { default as ProfileColumnStats } from './classes/ProfileColumnStats.ts';
export { default as ProfileColumnStatsEntry } from './classes/ProfileColumnStatsEntry.ts';
export { default as ProfileColumnUserInfo } from './classes/ProfileColumnUserInfo.ts';
export { default as Quiz } from './classes/Quiz.ts';
export { default as RecognitionShelf } from './classes/RecognitionShelf.ts';
export { default as ReelItem } from './classes/ReelItem.ts';
export { default as ReelShelf } from './classes/ReelShelf.ts';
@@ -273,6 +289,8 @@ export { default as RichShelf } from './classes/RichShelf.ts';
export { default as SearchBox } from './classes/SearchBox.ts';
export { default as SearchFilter } from './classes/SearchFilter.ts';
export { default as SearchFilterGroup } from './classes/SearchFilterGroup.ts';
export { default as SearchFilterOptionsDialog } from './classes/SearchFilterOptionsDialog.ts';
export { default as SearchHeader } from './classes/SearchHeader.ts';
export { default as SearchRefinementCard } from './classes/SearchRefinementCard.ts';
export { default as SearchSubMenu } from './classes/SearchSubMenu.ts';
export { default as SearchSuggestion } from './classes/SearchSuggestion.ts';
@@ -299,6 +317,7 @@ export { default as SingleHeroImage } from './classes/SingleHeroImage.ts';
export { default as SlimOwner } from './classes/SlimOwner.ts';
export { default as SlimVideoMetadata } from './classes/SlimVideoMetadata.ts';
export { default as SortFilterSubMenu } from './classes/SortFilterSubMenu.ts';
export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.ts';
export { default as SubFeedOption } from './classes/SubFeedOption.ts';
export { default as SubFeedSelector } from './classes/SubFeedSelector.ts';
export { default as SubscribeButton } from './classes/SubscribeButton.ts';
@@ -335,6 +354,9 @@ export { default as VerticalList } from './classes/VerticalList.ts';
export { default as VerticalWatchCardList } from './classes/VerticalWatchCardList.ts';
export { default as Video } from './classes/Video.ts';
export { default as VideoCard } from './classes/VideoCard.ts';
export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.ts';
export { default as VideoDescriptionInfocardsSection } from './classes/VideoDescriptionInfocardsSection.ts';
export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.ts';
export { default as VideoInfoCardContent } from './classes/VideoInfoCardContent.ts';
export { default as VideoOwner } from './classes/VideoOwner.ts';
export { default as VideoPrimaryInfo } from './classes/VideoPrimaryInfo.ts';

View File

@@ -264,6 +264,13 @@ export default class Parser {
parsed_data.cards = cards;
}
const engagement_panels = data.engagementPanels?.map((e) => {
const item = this.parseItem(e, YTNodes.EngagementPanelSectionList) as YTNodes.EngagementPanelSectionList;
return item;
});
if (engagement_panels) {
parsed_data.engagement_panels = engagement_panels;
}
this.#createMemo();
const items = this.parse(data.items);
if (items) {
@@ -406,6 +413,8 @@ export default class Parser {
return observe(actions.map((action: any) => {
if (action.navigateAction)
return new NavigateAction(action.navigateAction);
if (action.showMiniplayerCommand)
return new ShowMiniplayerCommand(action.showMiniplayerCommand);
if (action.reloadContinuationItemsCommand)
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
if (action.appendContinuationItemsAction)
@@ -502,14 +511,15 @@ export default class Parser {
'BrandVideoShelf',
'BrandVideoSingleton',
'StatementBanner',
'GuideSigninPromo'
'GuideSigninPromo',
'AdsEngagementPanelContent'
]);
static shouldIgnore(classname: string) {
return this.ignore_list.has(classname);
}
static #rt_nodes = new Map<string, YTNodeConstructor>(Array.from(Object.entries(YTNodes)));
static #rt_nodes = new Map<string, YTNodeConstructor>(Object.entries(YTNodes));
static #dynamic_nodes = new Map<string, YTNodeConstructor>();
static getParserByName(classname: string) {
@@ -566,6 +576,19 @@ export class NavigateAction extends YTNode {
}
}
export class ShowMiniplayerCommand extends YTNode {
static readonly type = 'showMiniplayerCommand';
miniplayer_command: NavigationEndpoint;
show_premium_branding: boolean;
constructor(data: RawNode) {
super();
this.miniplayer_command = new NavigationEndpoint(data.miniplayerCommand);
this.show_premium_branding = data.showPremiumBranding;
}
}
export class AppendContinuationItemsAction extends YTNode {
static readonly type = 'appendContinuationItemsAction';
@@ -739,4 +762,4 @@ export class LiveChatContinuation extends YTNode {
this.viewer_name = data.viewerName;
}
}
}

View File

@@ -17,7 +17,7 @@ import type VideoDetails from '../classes/misc/VideoDetails.ts';
import type Alert from '../classes/Alert.ts';
import type NavigationEndpoint from '../classes/NavigationEndpoint.ts';
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.ts';
import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.ts';
export interface IParsedResponse {
actions?: SuperParsedResult<YTNode>;
actions_memo?: Memo;
@@ -73,6 +73,7 @@ export interface IParsedResponse {
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
cards?: CardCollection;
engagement_panels?: EngagementPanelSectionList[];
items?: SuperParsedResult<YTNode>;
}
@@ -111,6 +112,7 @@ export interface INextResponse {
on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_endpoints_memo?: Memo;
player_overlays?: SuperParsedResult<YTNode>;
engagement_panels?: EngagementPanelSectionList[];
}
export interface IBrowseResponse {
@@ -163,4 +165,4 @@ export interface IUpdatedMetadataResponse {
export interface IGuideResponse {
items: SuperParsedResult<YTNode>;
items_memo: Memo;
}
}

View File

@@ -53,4 +53,5 @@ export interface IRawResponse {
cards?: RawNode;
items?: RawNode[];
frameworkUpdates?: any;
}
engagementPanels: RawNode[];
}

View File

@@ -9,6 +9,7 @@ import SubscribeButton from '../classes/SubscribeButton.ts';
import ExpandableTab from '../classes/ExpandableTab.ts';
import SectionList from '../classes/SectionList.ts';
import Tab from '../classes/Tab.ts';
import PageHeader from '../classes/PageHeader.ts';
import Feed from '../../core/mixins/Feed.ts';
import FilterableFeed from '../../core/mixins/FilterableFeed.ts';
@@ -25,7 +26,7 @@ import type { ApiResponse } from '../../core/Actions.ts';
import type { IBrowseResponse } from '../types/index.ts';
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);
@@ -163,6 +164,16 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return new Channel(this.actions, tab.page, true);
}
async getReleases(): Promise<Channel> {
const tab = await this.getTabByURL('releases');
return new Channel(this.actions, tab.page, true);
}
async getPodcasts(): Promise<Channel> {
const tab = await this.getTabByURL('podcasts');
return new Channel(this.actions, tab.page, true);
}
async getPlaylists(): Promise<Channel> {
const tab = await this.getTabByURL('playlists');
return new Channel(this.actions, tab.page, true);
@@ -217,6 +228,14 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return this.hasTabWithURL('streams');
}
get has_releases(): boolean {
return this.hasTabWithURL('releases');
}
get has_podcasts(): boolean {
return this.hasTabWithURL('podcasts');
}
get has_playlists(): boolean {
return this.hasTabWithURL('playlists');
}

View File

@@ -2,6 +2,7 @@ import Feed from '../../core/mixins/Feed.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import HorizontalCardList from '../classes/HorizontalCardList.ts';
import ItemSection from '../classes/ItemSection.ts';
import SearchHeader from '../classes/SearchHeader.ts';
import SearchRefinementCard from '../classes/SearchRefinementCard.ts';
import SearchSubMenu from '../classes/SearchSubMenu.ts';
import SectionList from '../classes/SectionList.ts';
@@ -11,8 +12,8 @@ import type Actions from '../../core/Actions.ts';
import type { ApiResponse } from '../../core/Actions.ts';
import type { ObservedArray, YTNode } from '../helpers.ts';
import type { ISearchResponse } from '../types/ParsedResponse.ts';
class Search extends Feed<ISearchResponse> {
header?: SearchHeader;
results?: ObservedArray<YTNode> | null;
refinements: string[];
estimated_results: number;
@@ -30,7 +31,10 @@ class Search extends Feed<ISearchResponse> {
if (!contents)
throw new InnertubeError('No contents found in search response');
this.results = contents.filterType(ItemSection).find((section) => section.contents && section.contents.length > 0)?.contents;
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 || [];
this.estimated_results = this.page.estimated_results;

View File

@@ -33,6 +33,8 @@ import type { ObservedArray, YTNode } from '../helpers.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import { MediaInfo } from '../../core/mixins/index.ts';
import StructuredDescriptionContent from '../classes/StructuredDescriptionContent.ts';
import { VideoDescriptionMusicSection } from '../nodes.ts';
class VideoInfo extends MediaInfo {
#watch_next_continuation?: ContinuationItem;
@@ -97,6 +99,28 @@ class VideoInfo extends MediaInfo {
this.captions = info.captions;
this.cards = info.cards;
if (this.streaming_data) {
const default_audio_track = this.streaming_data.adaptive_formats.find((format) => format.audio_track?.audio_is_default);
if (default_audio_track) {
// The combined formats only exist for the default language, even for videos with multiple audio tracks
// So we can copy the language from the default audio track to the combined formats
this.streaming_data.formats.forEach((format) => format.language = default_audio_track.language);
} else if (typeof this.captions?.default_audio_track_index !== 'undefined' && this.captions?.audio_tracks && this.captions.caption_tracks) {
// For videos with a single audio track and captions, we can use the captions to figure out the language of the audio and combined formats
const audioTrack = this.captions.audio_tracks[this.captions.default_audio_track_index];
const defaultCaptionTrackIndex = audioTrack.default_caption_track_index;
const index = audioTrack.caption_track_indices[defaultCaptionTrackIndex ? defaultCaptionTrackIndex : 0];
const language_code = this.captions.caption_tracks[index].language_code;
this.streaming_data.adaptive_formats.forEach((format) => {
if (format.has_audio) {
format.language = language_code;
}
});
this.streaming_data.formats.forEach((format) => format.language = language_code);
}
}
const two_col = next?.contents?.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;
@@ -336,41 +360,55 @@ class VideoInfo extends MediaInfo {
/**
* Get songs used in the video.
*/
// TODO: this seems to be broken with the new UI, further investigation needed
get music_tracks() {
/*
Const metadata = this.secondary_info?.metadata;
if (!metadata)
return [];
const songs = [];
let current_song: Record<string, Text[]> = {};
let is_music_section = false;
for (let i = 0; i < metadata.rows.length; i++) {
const row = metadata.rows[i];
if (row.is(MetadataRowHeader)) {
if (row.content?.toString().toLowerCase().startsWith('music')) {
is_music_section = true;
i++; // Skip the learn more link
}
continue;
}
if (!is_music_section)
continue;
if (row.is(MetadataRow))
current_song[row.title?.toString().toLowerCase().replace(/ /g, '_')] = row.contents;
// TODO: this makes no sense, we continue above when
if (row.has_divider_line) {
songs.push(current_song);
current_song = {};
}
// @TODO: Refactor this.
const description_content = this.page[1]?.engagement_panels?.filter((panel) => panel.content?.is(StructuredDescriptionContent));
if (description_content !== undefined && description_content.length > 0) {
const music_section = description_content[0].content?.as(StructuredDescriptionContent)?.items?.filterType(VideoDescriptionMusicSection);
if (music_section !== undefined && music_section.length > 0) {
return music_section[0].carousel_lockups?.map((lookup) => {
let song: string | undefined;
let artist: string | undefined;
let album: string | undefined;
let license: string | undefined;
let videoId: string | undefined;
let channelId: string | undefined;
}
if (is_music_section)
songs.push(current_song);
return songs;
*/
// If the song isn't in the video_lockup, it should be in the info_rows
song = lookup.video_lockup?.title?.toString();
// If the video id isn't in the video_lockup, it should be in the info_rows
videoId = lookup.video_lockup?.endpoint.payload.videoId;
for (let i = 0; i < lookup.info_rows.length; i++) {
const info_row = lookup.info_rows[i];
if (info_row.info_row_expand_status_key === undefined) {
if (song === undefined) {
song = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
if (videoId === undefined) {
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
videoId = endpoint?.payload?.videoId;
}
} else {
album = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
}
} else {
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-artists-row-state-id') !== -1) {
artist = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
if (channelId === undefined) {
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
channelId = endpoint?.payload?.browseId;
}
}
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-licenses-row-state-id') !== -1) {
license = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
}
}
}
return { song, artist, album, license, videoId, channelId };
});
}
}
return [];
}
}
export default VideoInfo;
export default VideoInfo;

View File

@@ -9,6 +9,8 @@ import type { ApiResponse } from '../../core/Actions.ts';
import type { ObservedArray } from '../helpers.ts';
import type { IBrowseResponse } from '../types/ParsedResponse.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import ChipCloud from '../classes/ChipCloud.ts';
import ChipCloudChip from '../classes/ChipCloudChip.ts';
class HomeFeed {
#page: IBrowseResponse;
@@ -16,6 +18,7 @@ class HomeFeed {
#continuation?: string;
sections?: ObservedArray<MusicCarouselShelf | MusicTastebuilderShelf>;
header?: ChipCloud;
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
@@ -36,6 +39,7 @@ class HomeFeed {
return;
}
this.header = tab.content?.as(SectionList).header?.as(ChipCloud);
this.#continuation = tab.content?.as(SectionList).continuation;
this.sections = tab.content?.as(SectionList).contents.as(MusicCarouselShelf, MusicTastebuilderShelf);
}
@@ -55,6 +59,33 @@ class HomeFeed {
return new HomeFeed(response, this.#actions);
}
async applyFilter(target_filter: string | ChipCloudChip): Promise<HomeFeed> {
let cloud_chip: ChipCloudChip | undefined;
if (typeof target_filter === 'string') {
cloud_chip = this.header?.chips?.as(ChipCloudChip).get({ text: target_filter });
if (!cloud_chip)
throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters });
} else if (target_filter?.is(ChipCloudChip)) {
cloud_chip = target_filter;
}
if (!cloud_chip)
throw new InnertubeError('Invalid filter', { available_filters: this.filters });
if (cloud_chip?.is_selected) return this;
if (!cloud_chip.endpoint)
throw new InnertubeError('Selected filter does not have an endpoint.');
const response = await cloud_chip.endpoint.call(this.#actions, { client: 'YTMUSIC' });
return new HomeFeed(response, this.#actions);
}
get filters(): string[] {
return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || [];
}
get has_continuation(): boolean {
return !!this.#continuation;
}

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

@@ -29,13 +29,13 @@ export const OAUTH = Object.freeze({
}),
REGEX: Object.freeze({
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)"},/
CLIENT_IDENTITY: /var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)".+?}/
})
});
export const CLIENTS = Object.freeze({
WEB: {
NAME: 'WEB',
VERSION: '2.20230104.01.00',
VERSION: '2.20230622.06.00',
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
API_VERSION: 'v1',
STATIC_VISITOR_ID: '6zpwvWUNAco'

View File

@@ -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';
@@ -146,7 +147,7 @@ class FormatUtils {
chunk_end += chunk_size;
resolve();
return;
} catch (e: any) {
reject(e);
}
@@ -245,26 +246,18 @@ 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');
let filtered_streaming_data;
let adaptive_formats;
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
};
adaptive_formats = streaming_data.adaptive_formats.filter((fmt: Format) => !(format_filter(fmt)));
} else {
filtered_streaming_data = streaming_data;
adaptive_formats = streaming_data.adaptive_formats;
}
const { adaptive_formats } = filtered_streaming_data;
if (!adaptive_formats.length)
throw new InnertubeError('No adaptive formats found');
@@ -288,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);
}
@@ -305,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[][] = [ [] ];
@@ -363,7 +365,12 @@ class FormatUtils {
this.#el(document, 'Role', {
schemeIdUri: 'urn:mpeg:dash:role:2011',
value: role
})
}),
this.#el(document, 'Label', {
id: set_id.toString()
}, [
document.createTextNode(first_format.audio_track?.display_name as string)
])
);
const set = this.#el(document, 'AdaptationSet', {
@@ -371,13 +378,22 @@ class FormatUtils {
mimeType: mime_types[i].split(';')[0],
startWithSAP: '1',
subsegmentAlignment: 'true',
lang: first_format.language as string
lang: first_format.language as string,
// Non-standard attribute used by shaka instead of the standard Label element
label: first_format.audio_track?.display_name as string
}, children);
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, track_objects[j], hoisted);
this.#hoistNumberAttributeIfPossible(set, track_objects[j], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, track_objects[j], hoisted);
period.appendChild(set);
for (const format of track_objects[j]) {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
} else {
@@ -388,43 +404,237 @@ class FormatUtils {
subsegmentAlignment: 'true'
});
const color_info = mime_objects[i][0].color_info;
if (typeof color_info !== 'undefined') {
// 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
if (color_info.primaries) {
let primaries = '';
switch (color_info.primaries) {
case 'BT709':
primaries = '1';
break;
case 'BT2020':
primaries = '9';
break;
}
if (primaries !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:ColourPrimaries',
value: primaries
}));
}
}
if (color_info.transfer_characteristics) {
let transfer_characteristics = '';
switch (color_info.transfer_characteristics) {
case 'BT709':
transfer_characteristics = '1';
break;
case 'BT2020_10':
transfer_characteristics = '14';
break;
case 'SMPTEST2084':
transfer_characteristics = '16';
break;
case 'ARIB_STD_B67':
transfer_characteristics = '18';
break;
}
if (transfer_characteristics !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:TransferCharacteristics',
value: transfer_characteristics
}));
}
}
if (color_info.matrix_coefficients) {
let matrix_coefficients = '';
// 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
switch (color_info.matrix_coefficients) {
case 'BT709':
matrix_coefficients = '1';
break;
case 'BT2020_NCL':
matrix_coefficients = '14';
break;
default: {
const format = mime_objects[i][0];
const url = new URL(format.url as string);
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);
}
}
if (matrix_coefficients !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:MatrixCoefficients',
value: matrix_coefficients
}));
}
}
}
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, mime_objects[i], hoisted);
if (mime_objects[i][0].has_audio) {
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, mime_objects[i], hoisted);
const language = mime_objects[i][0].language;
if (language) {
set.setAttribute('lang', language);
}
} else {
set.setAttribute('maxPlayoutRate', '1');
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'frameRate', 'fps', hoisted);
}
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);
await this.#generateRepresentationVideo(document, set, format, url_transformer, hoisted, cpn, player, actions);
} else {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
}
}
// 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 async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1) {
set.setAttribute('codecs', getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"') as string);
hoisted.push('codecs');
}
}
static #hoistNumberAttributeIfPossible(set: Element, formats: Format[], attribute: 'audioSamplingRate' | 'frameRate', property: 'audio_sample_rate' | 'fps', hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) {
set.setAttribute(attribute, formats[0][property]?.toString() as string);
hoisted.push(attribute);
}
}
static #hoistAudioChannelsIfPossible(document: XMLDocument, set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels?.toString() || '2')).size === 1) {
set.appendChild(
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: formats[0].audio_channels?.toString() || '2'
})
);
hoisted.push('AudioChannelConfiguration');
}
}
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
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()
height: format.height?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('frameRate')) {
representation.setAttribute('frameRate', format.fps?.toString() as string);
}
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="', '"');
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
@@ -437,21 +647,101 @@ class FormatUtils {
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'
})
]);
bandwidth: format.bitrate?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('audioSamplingRate')) {
representation.setAttribute('audioSamplingRate', format.audio_sample_rate?.toString() as string);
}
if (!hoisted.includes('AudioChannelConfiguration')) {
representation.appendChild(
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 #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) {