Compare commits

..

7 Commits

Author SHA1 Message Date
LuanRT
ac9341c769 chore(release): v2.6.0 2022-12-19 04:07:48 -03:00
LuanRT
cac762569a feat(Session): allow overriding geolocation (#260)
* Allow overriding geolocation

* Fix some inconsistencies (unrelated)
2022-12-19 03:55:38 -03:00
LuanRT
9978ebf085 refactor(Parser): reduce reliance on localised strings (#258) 2022-12-17 00:54:08 -03:00
LuanRT
b036e2fcdc feat(Channel): parse subscribe button
This way one can subscribe to a given channel simply by calling the button's endpoint.
2022-12-16 17:13:13 -03:00
LuanRT
e37f42f41b feat: bring back Video#is_live and add ExpandableMetadata (#256)
* bring back `Video#is_live`

* add ExpandableMetadata
2022-12-15 19:01:42 -03:00
absidue
883a023624 feat(TextRun): add support for formatting (#254) 2022-12-14 22:48:35 -03:00
LuanRT
506834b253 docs: fix formatting (oops) 2022-12-12 01:18:42 -03:00
37 changed files with 335 additions and 148 deletions

View File

@@ -120,7 +120,7 @@ Create an InnerTube instance:
// const { Innertube } = require('youtubei.js');
import { Innertube } from 'youtubei.js';
const youtube = await Innertube.create();
```****
```
## Browser Usage
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
@@ -582,23 +582,23 @@ For example, let's say we want to implement a method to retrieve video info manu
```ts
import { Innertube } from 'youtubei.js';
const yt = await Innertube.create();
(async () => {
const yt = await Innertube.create();
async function getVideoInfo(videoId: string) {
const payload = {
// anything added here will be merged with the default payload and sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
};
async function getVideoInfo(videoId: string) {
const videoInfo = await yt.actions.execute('/player', {
// anything added here will be merged with the default payload and sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED
parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube.
});
const videoInfo = await yt.actions.execute('/player', payload);
return videoInfo;
}
return videoInfo;
}
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
console.info(videoInfo);
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
console.info(videoInfo);
})();
```
Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
@@ -606,19 +606,21 @@ Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to ca
```ts
import { Innertube, YTNodes } from 'youtubei.js';
const yt = await Innertube.create();
(async () => {
const yt = await Innertube.create();
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);
// Say we want to click the “More” button:
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
// Say we want to click the “More” button:
const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;
if (button) {
// After making sure it exists, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions);
console.info(page);
}
if (button) {
// After making sure it exists, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions);
console.info(page);
}
})();
```
### Parser

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "2.5.2",
"version": "2.6.0",
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",

View File

@@ -11,9 +11,6 @@ import Playlist from '../parser/ytmusic/Playlist';
import Recap from '../parser/ytmusic/Recap';
import Tab from '../parser/classes/Tab';
import Tabbed from '../parser/classes/Tabbed';
import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults';
import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults';
import SectionList from '../parser/classes/SectionList';
import Message from '../parser/classes/Message';
@@ -235,13 +232,9 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Up next' });
const tab = tabs?.[0];
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -287,20 +280,16 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Related' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
}
@@ -318,13 +307,9 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo.getType(Tab);
const tab = tabs.get({ title: 'Lyrics' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -334,7 +319,7 @@ class Music {
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
const section_list = page.contents.item().as(SectionList).contents.array();
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}

View File

@@ -56,6 +56,7 @@ export interface Context {
export interface SessionOptions {
lang?: string;
location?: string;
account_index?: number;
device_category?: DeviceCategory;
client_type?: ClientType;
@@ -112,6 +113,7 @@ export default class Session extends EventEmitterLike {
static async create(options: SessionOptions = {}) {
const { context, api_key, api_version, account_index } = await Session.getSessionData(
options.lang,
options.location,
options.account_index,
options.device_category,
options.client_type,
@@ -123,6 +125,7 @@ export default class Session extends EventEmitterLike {
static async getSessionData(
lang = 'en-US',
location = '',
account_index = 0,
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
@@ -157,7 +160,7 @@ export default class Session extends EventEmitterLike {
const context: Context = {
client: {
hl: device_info[0],
gl: device_info[2],
gl: location || device_info[2],
remoteHost: device_info[3],
screenDensityFloat: 1,
screenHeightPoints: 720,

View File

@@ -17,7 +17,7 @@ class TabbedFeed extends Feed {
return this.#tabs.map((tab) => tab.title.toString());
}
async getTab(title: string) {
async getTabByName(title: string) {
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
@@ -28,8 +28,19 @@ class TabbedFeed extends Feed {
const response = await tab.endpoint.call(this.#actions);
if (!response)
throw new InnertubeError('Failed to call endpoint');
return new TabbedFeed(this.#actions, response.data, false);
}
async getTabByURL(url: string) {
const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
if (!tab)
throw new InnertubeError(`Tab "${url}" not found`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed(this.#actions, response.data, false);
}

View File

@@ -6,17 +6,21 @@ import { YTNode } from '../helpers';
class Button extends YTNode {
static type = 'Button';
text: string;
text?: string;
label;
tooltip;
icon_type;
label?: string;
tooltip?: string;
icon_type?: string;
is_disabled?: boolean;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.text = new Text(data.text).toString();
if (data.text) {
this.text = new Text(data.text).toString();
}
if (data.accessibility?.label) {
this.label = data.accessibility?.label;
@@ -30,6 +34,10 @@ class Button extends YTNode {
this.icon_type = data.icon?.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}
}

View File

@@ -1,20 +1,28 @@
import Parser from '../index';
import Author from './misc/Author';
import Thumbnail from './misc/Thumbnail';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import type Button from './Button';
import type ChannelHeaderLinks from './ChannelHeaderLinks';
import type SubscribeButton from './SubscribeButton';
import { YTNode } from '../helpers';
class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author;
banner;
tv_banner;
mobile_banner;
subscribers;
sponsor_button;
subscribe_button;
header_links;
author: Author;
banner: Thumbnail[];
tv_banner: Thumbnail[];
mobile_banner: Thumbnail[];
subscribers: Text;
videos_count: Text;
sponsor_button: Button | null;
subscribe_button: SubscribeButton | null;
header_links: ChannelHeaderLinks | null;
channel_handle: Text;
channel_id: string;
constructor(data: any) {
super();
@@ -23,13 +31,16 @@ class C4TabbedHeader extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
this.banner = data.banner ? Thumbnail.fromResponse(data.banner) : [];
this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : [];
this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : [];
this.banner = Thumbnail.fromResponse(data.banner);
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
this.subscribers = new Text(data.subscriberCountText);
this.sponsor_button = data.sponsorButton ? Parser.parseItem(data.sponsorButton) : undefined;
this.subscribe_button = data.subscribeButton ? Parser.parseItem(data.subscribeButton) : undefined;
this.header_links = data.headerLinks ? Parser.parse(data.headerLinks) : undefined;
this.videos_count = new Text(data.videosCountText);
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
this.channel_handle = new Text(data.channelHandleText);
this.channel_id = data.channelId;
}
}

View File

@@ -1,6 +1,11 @@
import Parser from '..';
import Text from './misc/Text';
import Author from './misc/Author';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import type SubscribeButton from './SubscribeButton';
import { YTNode } from '../helpers';
class Channel extends YTNode {
@@ -10,7 +15,10 @@ class Channel extends YTNode {
author: Author;
subscribers: Text;
videos: Text;
long_byline: Text;
short_byline: Text;
endpoint: NavigationEndpoint;
subscribe_button: SubscribeButton | null;
description_snippet: Text;
constructor(data: any) {
@@ -22,9 +30,13 @@ class Channel extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.ownerBadges, data.thumbnail);
// TODO: subscriberCountText is now the channel's handle and videoCountText is the subscriber count. Why haven't they renamed the properties?
this.subscribers = new Text(data.subscriberCountText);
this.videos = new Text(data.videoCountText);
this.long_byline = new Text(data.longBylineText);
this.short_byline = new Text(data.shortBylineText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.description_snippet = new Text(data.descriptionSnippet);
}
}

View File

@@ -1,7 +1,11 @@
import Parser from '../index';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import Parser from '../index';
import type Button from './Button';
import { YTNode } from '../helpers';
class ChannelAboutFullMetadata extends YTNode {
@@ -11,13 +15,20 @@ class ChannelAboutFullMetadata extends YTNode {
name: Text;
avatar: Thumbnail[];
canonical_channel_url: string;
primary_links: {
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
}[];
views: Text;
joined: Text;
description: Text;
email_reveal: NavigationEndpoint;
can_reveal_email: boolean;
country: Text;
buttons;
buttons: Button[];
constructor(data: any) {
super();
@@ -25,13 +36,20 @@ class ChannelAboutFullMetadata extends YTNode {
this.name = new Text(data.title);
this.avatar = Thumbnail.fromResponse(data.avatar);
this.canonical_channel_url = data.canonicalChannelUrl;
this.primary_links = data.primaryLinks.map((link: any) => ({
endpoint: new NavigationEndpoint(link.navigationEndpoint),
icon: Thumbnail.fromResponse(link.icon),
title: new Text(link.title)
}));
this.views = new Text(data.viewCountText);
this.joined = new Text(data.joinedDateText);
this.description = new Text(data.description);
this.email_reveal = new NavigationEndpoint(data.onBusinessEmailRevealClickCommand);
this.can_reveal_email = !data.signInForBusinessEmail;
this.country = new Text(data.country);
this.buttons = Parser.parse(data.actionButtons);
this.buttons = Parser.parseArray<Button>(data.actionButtons);
}
}

View File

@@ -4,6 +4,8 @@ import Author from './misc/Author';
import { timeToSeconds } from '../../utils/Utils';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class CompactVideo extends YTNode {
@@ -25,7 +27,7 @@ class CompactVideo extends YTNode {
thumbnail_overlays;
endpoint: NavigationEndpoint;
menu;
menu: Menu | null;
constructor(data: any) {
super();
@@ -43,9 +45,9 @@ class CompactVideo extends YTNode {
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
}
get best_thumbnail() {

View File

@@ -0,0 +1,41 @@
import Parser from '..';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import Button from './Button';
import HorizontalCardList from './HorizontalCardList';
import { YTNode } from '../helpers';
class ExpandableMetadata extends YTNode {
static type = 'ExpandableMetadata';
header: {
collapsed_title: Text;
collapsed_thumbnail: Thumbnail[];
collapsed_label: Text;
expanded_title: Text;
};
expanded_content: HorizontalCardList | null;
expand_button: Button | null;
collapse_button: Button | null;
constructor(data: any) {
super();
this.header = {
collapsed_title: new Text(data.header.collapsedTitle),
collapsed_thumbnail: Thumbnail.fromResponse(data.header.collapsedThumbnail),
collapsed_label: new Text(data.header.collapsedLabel),
expanded_title: new Text(data.header.expandedTitle)
};
this.expanded_content = Parser.parseItem<HorizontalCardList>(data.expandedContent);
this.expand_button = Parser.parseItem<Button>(data.expandButton);
this.collapse_button = Parser.parseItem<Button>(data.collapseButton);
}
}
export default ExpandableMetadata;

View File

@@ -3,6 +3,9 @@ import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Author from './misc/Author';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class GridVideo extends YTNode {
@@ -14,12 +17,12 @@ class GridVideo extends YTNode {
thumbnail_overlays;
rich_thumbnail;
published: Text;
duration: Text | string;
duration: Text | null;
author: Author;
views: Text;
short_view_count: Text;
endpoint: NavigationEndpoint;
menu;
menu: Menu | null;
constructor(data: any) {
super();
@@ -27,15 +30,15 @@ class GridVideo extends YTNode {
this.id = data.videoId;
this.title = new Text(data.title);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail);
this.published = new Text(data.publishedTimeText);
this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : '';
this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : null;
this.author = data.shortBylineText && new Author(data.shortBylineText, data.ownerBadges);
this.views = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
}
}

View File

@@ -2,6 +2,7 @@ import Parser from '../index';
import { YTNode } from '../helpers';
import SearchRefinementCard from './SearchRefinementCard';
import Button from './Button';
import MacroMarkersListItem from './MacroMarkersListItem';
class HorizontalCardList extends YTNode {
static type = 'HorizontalCardList';
@@ -13,7 +14,7 @@ class HorizontalCardList extends YTNode {
constructor(data: any) {
super();
this.cards = Parser.parseArray<SearchRefinementCard>(data.cards, SearchRefinementCard);
this.cards = Parser.parseArray<SearchRefinementCard | MacroMarkersListItem>(data.cards);
this.header = Parser.parseItem(data.header);
this.previous_button = Parser.parseItem<Button>(data.previousButton, Button);
this.next_button = Parser.parseItem<Button>(data.nextButton, Button);

View File

@@ -0,0 +1,29 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
class MacroMarkersListItem extends YTNode {
static type = 'MacroMarkersListItem';
title: Text;
time_description: Text;
thumbnail: Thumbnail[];
on_tap_endpoint: NavigationEndpoint;
layout: string;
is_highlighted: boolean;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.time_description = new Text(data.timeDescription);
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
this.on_tap_endpoint = new NavigationEndpoint(data.onTap);
this.layout = data.layout;
this.is_highlighted = data.isHighlighted;
}
}
export default MacroMarkersListItem;

View File

@@ -5,7 +5,8 @@ class MetadataBadge extends YTNode {
icon_type?: string;
style?: string;
tooltip: string | null;
label?: string;
tooltip?: string;
constructor(data: any) {
super();
@@ -18,7 +19,13 @@ class MetadataBadge extends YTNode {
this.style = data.style;
}
this.tooltip = data?.tooltip || data?.iconTooltip || null;
if (data?.label) {
this.style = data.label;
}
if (data?.tooltip || data?.iconTooltip) {
this.tooltip = data.tooltip || data.iconTooltip;
}
}
}

View File

@@ -76,8 +76,8 @@ class PlaylistPanelVideo extends YTNode {
}));
}
this.badges = Parser.parse(data.badges);
this.menu = Parser.parse(data.menu);
this.badges = Parser.parseArray(data.badges);
this.menu = Parser.parseItem(data.menu);
this.set_video_id = data.playlistSetVideoId;
}
}

View File

@@ -3,6 +3,8 @@ import Parser from '../index';
import Thumbnail from './misc/Thumbnail';
import PlaylistAuthor from './misc/PlaylistAuthor';
import NavigationEndpoint from './NavigationEndpoint';
import type Menu from './menus/Menu';
import { YTNode } from '../helpers';
class PlaylistVideo extends YTNode {
@@ -17,7 +19,7 @@ class PlaylistVideo extends YTNode {
set_video_id: string | undefined;
endpoint: NavigationEndpoint;
is_playable: boolean;
menu;
menu: Menu | null;
duration: {
text: string;
@@ -35,7 +37,7 @@ class PlaylistVideo extends YTNode {
this.set_video_id = data?.setVideoId;
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.is_playable = data.isPlayable;
this.menu = Parser.parse(data.menu);
this.menu = Parser.parseItem<Menu>(data.menu);
this.duration = {
text: new Text(data.lengthText).text,
seconds: parseInt(data.lengthSeconds)

View File

@@ -16,7 +16,7 @@ class SectionList extends YTNode {
}
// TODO: this should be Parser#parseArray
this.contents = Parser.parse(data.contents);
this.contents = Parser.parseArray(data.contents);
if (data.continuations) {
if (data.continuations[0].nextContinuationData) {

View File

@@ -4,6 +4,8 @@ import Author from './misc/Author';
import Menu from './menus/Menu';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import MetadataBadge from './MetadataBadge';
import ExpandableMetadata from './ExpandableMetadata';
import { timeToSeconds } from '../../utils/Utils';
import { YTNode } from '../helpers';
@@ -18,11 +20,13 @@ class Video extends YTNode {
text: Text;
hover_text: Text;
}[];
expandable_metadata: ExpandableMetadata | null;
thumbnails: Thumbnail[];
thumbnail_overlays;
rich_thumbnail;
author: Author;
badges: MetadataBadge[];
endpoint: NavigationEndpoint;
published: Text;
view_count: Text;
@@ -36,7 +40,8 @@ class Video extends YTNode {
show_action_menu: boolean;
is_watched: boolean;
menu;
menu: Menu | null;
search_video_result_entity_key: string;
constructor(data: any) {
super();
@@ -54,10 +59,13 @@ class Video extends YTNode {
hover_text: new Text(snippet.snippetHoverText)
})) || [];
this.expandable_metadata = Parser.parseItem<ExpandableMetadata>(data.expandableMetadata);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.rich_thumbnail = data.richThumbnail ? Parser.parseItem(data.richThumbnail) : null;
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.published = new Text(data.publishedTimeText);
this.view_count = new Text(data.viewCountText);
@@ -76,6 +84,7 @@ class Video extends YTNode {
this.show_action_menu = data.showActionMenu;
this.is_watched = data.isWatched || false;
this.menu = Parser.parseItem<Menu>(data.menu, Menu);
this.search_video_result_entity_key = data.searchVideoResultEntityKey;
}
get description(): string {
@@ -85,22 +94,30 @@ class Video extends YTNode {
return this.description_snippet?.toString() || '';
}
/*
Get is_live() {
return this.badges.some((badge) => badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW');
get is_live(): boolean {
return this.badges.some((badge) => {
if (badge.label === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.style === 'LIVE')
return true;
});
}
*/
get is_upcoming(): boolean | undefined {
return this.upcoming && this.upcoming > new Date();
}
/*
Get has_captions() {
return this.badges.some((badge) => badge.label === 'CC');
}*/
get is_premiere(): boolean {
return this.badges.some((badge) => badge.style === 'PREMIERE');
}
get best_thumbnail(): Thumbnail | undefined{
get is_4k(): boolean {
return this.badges.some((badge) => badge.style === '4K');
}
get has_captions(): boolean {
return this.badges.some((badge) => badge.style === 'CC');
}
get best_thumbnail(): Thumbnail | undefined {
return this.thumbnails[0];
}
}

View File

@@ -3,9 +3,15 @@ import NavigationEndpoint from '../NavigationEndpoint';
class TextRun {
text: string;
endpoint: NavigationEndpoint | undefined;
bold: boolean;
italics: boolean;
strikethrough: boolean;
constructor(data: any) {
this.text = data.text;
this.bold = Boolean(data.bold);
this.italics = Boolean(data.italics);
this.strikethrough = Boolean(data.strikethrough);
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined;
}
}

View File

@@ -359,6 +359,10 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
* Returns all objects that match the rule.
*/
getAll: (rule: object, del_items?: boolean) => T[];
/**
* Returns the first object to match the condition.
*/
matchCondition: (condition: (node: T) => boolean) => T | undefined;
/**
* Removes the item at the given index.
*/
@@ -412,6 +416,14 @@ export function observe<T extends YTNode>(obj: Array<T>) {
);
}
if (prop == 'matchCondition') {
return (condition: (node: T) => boolean) => (
target.find((obj) => {
return condition(obj);
})
);
}
if (prop == 'filterType') {
return (...types: YTNodeConstructor<YTNode>[]) => {
return observe(target.filter((node: YTNode) => {

View File

@@ -73,6 +73,7 @@ import { default as Endscreen } from './classes/Endscreen';
import { default as EndscreenElement } from './classes/EndscreenElement';
import { default as EndScreenPlaylist } from './classes/EndScreenPlaylist';
import { default as EndScreenVideo } from './classes/EndScreenVideo';
import { default as ExpandableMetadata } from './classes/ExpandableMetadata';
import { default as ExpandableTab } from './classes/ExpandableTab';
import { default as ExpandedShelfContents } from './classes/ExpandedShelfContents';
import { default as FeedFilterChipBar } from './classes/FeedFilterChipBar';
@@ -137,6 +138,7 @@ import { default as LiveChatItemList } from './classes/LiveChatItemList';
import { default as LiveChatMessageInput } from './classes/LiveChatMessageInput';
import { default as LiveChatParticipant } from './classes/LiveChatParticipant';
import { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList';
import { default as MacroMarkersListItem } from './classes/MacroMarkersListItem';
import { default as Menu } from './classes/menus/Menu';
import { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem';
import { default as MenuServiceItem } from './classes/menus/MenuServiceItem';
@@ -363,6 +365,7 @@ export const YTNodes = {
EndscreenElement,
EndScreenPlaylist,
EndScreenVideo,
ExpandableMetadata,
ExpandableTab,
ExpandedShelfContents,
FeedFilterChipBar,
@@ -427,6 +430,7 @@ export const YTNodes = {
LiveChatMessageInput,
LiveChatParticipant,
LiveChatParticipantsList,
MacroMarkersListItem,
Menu,
MenuNavigationItem,
MenuServiceItem,

View File

@@ -9,12 +9,13 @@ import MicroformatData from '../classes/MicroformatData';
import SubscribeButton from '../classes/SubscribeButton';
import Tab from '../classes/Tab';
import { InnertubeError } from '../../utils/Utils';
import FeedFilterChipBar from '../classes/FeedFilterChipBar';
import ChipCloudChip from '../classes/ChipCloudChip';
import FilterableFeed from '../../core/FilterableFeed';
import Feed from '../../core/Feed';
import { InnertubeError } from '../../utils/Utils';
export default class Channel extends TabbedFeed {
header;
metadata;
@@ -70,37 +71,37 @@ export default class Channel extends TabbedFeed {
}
async getHome() {
const tab = await this.getTab('Home');
const tab = await this.getTabByURL('featured');
return new Channel(this.actions, tab.page, true);
}
async getVideos() {
const tab = await this.getTab('Videos');
const tab = await this.getTabByURL('videos');
return new Channel(this.actions, tab.page, true);
}
async getShorts() {
const tab = await this.getTab('Shorts');
const tab = await this.getTabByURL('shorts');
return new Channel(this.actions, tab.page, true);
}
async getLiveStreams() {
const tab = await this.getTab('Live');
const tab = await this.getTabByURL('streams');
return new Channel(this.actions, tab.page, true);
}
async getPlaylists() {
const tab = await this.getTab('Playlists');
const tab = await this.getTabByURL('playlists');
return new Channel(this.actions, tab.page, true);
}
async getCommunity() {
const tab = await this.getTab('Community');
const tab = await this.getTabByURL('community');
return new Channel(this.actions, tab.page, true);
}
async getChannels() {
const tab = await this.getTab('Channels');
const tab = await this.getTabByURL('channels');
return new Channel(this.actions, tab.page, true);
}
@@ -109,7 +110,7 @@ export default class Channel extends TabbedFeed {
* Note that this does not return a new {@link Channel} object.
*/
async getAbout() {
const tab = await this.getTab('About');
const tab = await this.getTabByURL('about');
return tab.memo.getType(ChannelAboutFullMetadata)?.[0];
}

View File

@@ -20,7 +20,7 @@ export default class Search extends Feed {
super(actions, data, already_parsed);
const contents =
this.page.contents_memo.getType(SectionList)?.[0]?.contents?.array() ||
this.page.contents_memo.getType(SectionList)?.[0]?.contents ||
this.page.on_response_received_commands?.[0].contents;
this.results = contents.firstOfType(ItemSection)?.contents;
@@ -40,7 +40,7 @@ export default class Search extends Feed {
if (typeof card === 'string') {
if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.');
target_card = this.refinement_cards?.cards.get({ query: card });
target_card = this.refinement_cards?.cards.get({ query: card })?.as(SearchRefinementCard);
if (!target_card)
throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
} else if (card.type === 'SearchRefinementCard') {
@@ -58,7 +58,7 @@ export default class Search extends Feed {
* Returns a list of refinement card queries.
*/
get refinement_card_queries() {
return this.refinement_cards?.cards.map((card) => card.query);
return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query);
}
/**

View File

@@ -31,7 +31,7 @@ class Settings {
if (!tab)
throw new InnertubeError('Target tab not found');
const contents = tab.content?.as(SectionList).contents.array().as(ItemSection);
const contents = tab.content?.as(SectionList).contents.as(ItemSection);
this.introduction = contents?.shift()?.contents?.get({ type: 'PageIntroduction' })?.as(PageIntroduction);

View File

@@ -19,7 +19,7 @@ class TimeWatched {
if (!tab)
throw new InnertubeError('Could not find target tab.');
this.contents = tab.content?.as(SectionList).contents.array().as(ItemSection);
this.contents = tab.content?.as(SectionList).contents.as(ItemSection);
}
get page(): ParsedResponse {

View File

@@ -28,8 +28,8 @@ class Explore {
if (!section_list)
throw new InnertubeError('Target tab did not have any content.');
this.top_buttons = section_list.contents.array().firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.sections = section_list.contents.array().getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[];
this.top_buttons = section_list.contents.firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.sections = section_list.contents.getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[];
}
get page(): ParsedResponse {

View File

@@ -33,7 +33,7 @@ class HomeFeed {
}
this.#continuation = tab.content?.as(SectionList).continuation;
this.sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf);
this.sections = tab.content?.as(SectionList).contents.as(MusicCarouselShelf);
}
/**

View File

@@ -30,7 +30,7 @@ class Library {
const section_list = this.#page.contents_memo.getType(SectionList)?.[0];
this.header = section_list?.header?.item().as(MusicSideAlignedItem);
this.contents = section_list?.contents?.array().as(Grid, MusicShelf);
this.contents = section_list?.contents?.as(Grid, MusicShelf);
this.#continuation = this.contents?.find((list: Grid | MusicShelf) => list.continuation)?.continuation;
}

View File

@@ -61,7 +61,7 @@ class Playlist {
/**
* Retrieves related playlists
*/
async getRelated() {
async getRelated(): Promise<MusicCarouselShelf> {
let section_continuation = this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation;
while (section_continuation) {
@@ -74,19 +74,15 @@ class Playlist {
const section_list = data.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const related = sections?.filter(
(section) =>
section.is(MusicCarouselShelf) ? section.header?.title.toString() === 'Related playlists' :
section.title.toString() === 'Related playlists'
)[0];
const related = sections?.matchCondition((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf);
if (related)
return related.contents || [];
return related;
section_continuation = section_list?.continuation;
}
return [];
throw new InnertubeError('Target section not found.');
}
async getSuggestions(refresh = true) {
@@ -115,9 +111,7 @@ class Playlist {
const section_list = page.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const suggestions = sections?.filter(
(section) => section.is(MusicShelf) && section.title.toString() === 'Suggestions'
)[0] as MusicShelf | undefined;
const suggestions = sections?.matchCondition((section) => section.is(MusicShelf))?.as(MusicShelf);
return {
items: suggestions?.contents || [],

View File

@@ -37,7 +37,7 @@ class Recap {
if (!tab)
throw new InnertubeError('Target tab not found');
this.sections = tab.content?.as(SectionList).contents.array().as(ItemSection, MusicCarouselShelf, Message);
this.sections = tab.content?.as(SectionList).contents.as(ItemSection, MusicCarouselShelf, Message);
}
/**

View File

@@ -49,7 +49,7 @@ class Search {
this.header = tab_content.hasKey('header') ? tab_content.header?.item().as(ChipCloud) : null;
const shelves = tab_content.contents.array().as(MusicShelf, ItemSection);
const shelves = tab_content.contents.as(MusicShelf, ItemSection);
const item_section = shelves.firstOfType(ItemSection);
this.did_you_mean = item_section?.contents?.firstOfType(DidYouMean) || null;

View File

@@ -4,9 +4,7 @@ import Constants from '../../utils/Constants';
import { InnertubeError } from '../../utils/Utils';
import Tab from '../classes/Tab';
import Tabbed from '../classes/Tabbed';
import WatchNextTabbedResults from '../classes/WatchNextTabbedResults';
import SingleColumnMusicWatchNextResults from '../classes/SingleColumnMusicWatchNextResults';
import MicroformatData from '../classes/MicroformatData';
import PlayerOverlay from '../classes/PlayerOverlay';
import PlaylistPanel from '../classes/PlaylistPanel';
@@ -70,8 +68,7 @@ class TrackInfo {
this.#playback_tracking = info.playback_tracking;
if (next) {
const single_col = next.contents.item().as(SingleColumnMusicWatchNextResults);
const tabbed_results = single_col.contents.item().as(Tabbed).contents.item().as(WatchNextTabbedResults);
const tabbed_results = next.contents_memo.getType(WatchNextTabbedResults)?.[0];
this.tabs = tabbed_results.tabs.array().as(Tab);
this.current_video_endpoint = next.current_video_endpoint;
@@ -84,14 +81,17 @@ class TrackInfo {
/**
* Retrieves contents of the given tab.
*/
async getTab(title: string) {
async getTab(title_or_page_type: string) {
if (!this.tabs)
throw new InnertubeError('Could not find any tab');
const target_tab = this.tabs.get({ title });
const target_tab =
this.tabs.get({ title: title_or_page_type }) ||
this.tabs.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === title_or_page_type) ||
this.tabs?.[0];
if (!target_tab)
throw new InnertubeError(`Tab "${title}" not found`, { available_tabs: this.available_tabs });
throw new InnertubeError(`Tab "${title_or_page_type}" not found`, { available_tabs: this.available_tabs });
if (target_tab.content)
return target_tab.content;
@@ -101,7 +101,7 @@ class TrackInfo {
if (page.contents.item().key('type').string() === 'Message')
return page.contents.item().as(Message);
return page.contents.item().as(SectionList).contents.array();
return page.contents.item().as(SectionList).contents;
}
/**
@@ -140,7 +140,7 @@ class TrackInfo {
* Retrieves related content.
*/
async getRelated(): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
const tab = await this.getTab('Related') as ObservedArray<MusicDescriptionShelf | MusicDescriptionShelf>;
const tab = await this.getTab('MUSIC_PAGE_TYPE_TRACK_RELATED') as ObservedArray<MusicDescriptionShelf | MusicDescriptionShelf>;
return tab;
}
@@ -148,7 +148,7 @@ class TrackInfo {
* Retrieves lyrics.
*/
async getLyrics(): Promise<MusicDescriptionShelf | undefined> {
const tab = await this.getTab('Lyrics') as ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>;
const tab = await this.getTab('MUSIC_PAGE_TYPE_TRACK_LYRICS') as ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>;
return tab.firstOfType(MusicDescriptionShelf);
}

View File

@@ -48,7 +48,7 @@ export default class HTTPClient {
const request_headers = new Headers(headers);
request_headers.set('Accept', '*/*');
request_headers.set('Accept-Language', `en-${this.#session.context.client.gl || 'US'}`);
request_headers.set('Accept-Language', `${this.#session.context.client.hl}-${this.#session.context.client.gl}`);
request_headers.set('x-goog-visitor-id', this.#session.context.client.visitorData || '');
request_headers.set('x-origin', request_url.origin);
request_headers.set('x-youtube-client-version', this.#session.context.client.clientVersion || '');

View File

@@ -10,6 +10,10 @@ export const VIDEOS = [
{
ID: 'I1qsF0WQy8c',
QUERY: 'mkbhd',
},
{
ID: 'OqiXFXlYFi8',
QUERY: 'formatted comment text'
}
];

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import Innertube from '..';
import { CHANNELS, VIDEOS } from './constants';
import { streamToIterable } from '../src/utils/Utils';
import TextRun from '../src/parser/classes/misc/TextRun';
describe('YouTube.js Tests', () => {
let yt: Innertube;
@@ -68,6 +69,19 @@ describe('YouTube.js Tests', () => {
threads = await yt.getComments(VIDEOS[1].ID);
expect(threads.contents.length).toBeGreaterThan(0);
});
it('should parse formatted comments', async () => {
const threads = await yt.getComments(VIDEOS[3].ID);
const authorComment = threads.contents.find(t => t.comment?.author_is_channel_owner)
expect(authorComment).not.toBeUndefined();
expect(authorComment!.comment?.content.runs?.length).toBeGreaterThan(0)
const runs = authorComment!.comment!.content.runs! as TextRun[]
expect(runs[0].bold).toBeTruthy()
expect(runs[2].italics).toBeTruthy()
expect(runs[4].strikethrough).toBeTruthy()
})
it('should retrieve next batch of comments', async () => {
const next = await threads.getContinuation();