Compare commits

..

3 Commits

Author SHA1 Message Date
LuanRT
c9856a8359 fix: search continuations not being parsed correctly (#173)
* feat: add `TitleAndButtonListHeader`

* fix: continuations not being parsed correctly

* chore: add a test

* chore(package): bump version to 2.0.2

* chore: lint
2022-09-08 21:31:07 -03:00
LuanRT
4b29ad74de chore(docs): rephrase a few things 2022-09-07 03:23:51 -03:00
Patrick Kan
60730a5531 fix: Music#getArtist() and DropdownItem (#170)
* fix: `Music#getArtist()` fails for private artist

* fix: `DropdownItem` inconsistent prop naming
2022-09-06 14:29:29 -03:00
10 changed files with 51 additions and 19 deletions

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "2.0.0",
"version": "2.0.2",
"description": "Full-featured wrapper around YouTube's private API.",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",
@@ -69,17 +69,18 @@
"youtubedl",
"youtube-dl",
"youtube-downloader",
"innertube",
"youtube-music",
"innertubeapi",
"innertube",
"unofficial",
"downloader",
"livechat",
"studio",
"upload",
"ytmusic",
"dislike",
"search",
"comment",
"music",
"like",
"api"
]
}

View File

@@ -90,7 +90,7 @@ class Music {
async getArtist(artist_id: string) {
throwIfMissing({ artist_id });
if (!artist_id.startsWith('UC'))
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });

View File

@@ -225,7 +225,7 @@ const videos = response.contents_memo.getType(Video);
If you decompile a YouTube client and analize it for a while you will notice that it has classes named `protos/youtube/api/innertube/MusicItemRenderer`, `protos/youtube/api/innertube/SectionListRenderer`, etc.
These classes are used to parse objects from the response (which consists of protobuf messages) and also build requests. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
These classes are used to parse objects from the response, map them into models and generate the UI. The website works in a similar way, the difference is that it uses plain JSON (likely converted from protobuf server-side, hence the weird structure of the response).
Here we're taking a similar approach, the parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, and on top of that it also parses navigation endpoints which allows us to make an API call with all required parameters in one line and even emulate client actions (eg; clicking a button).

View File

@@ -8,7 +8,7 @@ class DropdownItem extends YTNode {
label: string;
selected: boolean;
value?: number | string;
iconType?: string;
icon_type?: string;
description?: string;
endpoint?: NavigationEndpoint;
@@ -29,7 +29,7 @@ class DropdownItem extends YTNode {
}
if (data.icon?.iconType) {
this.iconType = data.icon?.iconType;
this.icon_type = data.icon?.iconType;
}
if (data.descriptionText) {

View File

@@ -0,0 +1,15 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
class TitleAndButtonListHeader extends YTNode {
static type = 'TitleAndButtonListHeader';
title: Text;
constructor(data: any) {
super();
this.title = new Text(data.title);
}
}
export default TitleAndButtonListHeader;

View File

@@ -244,6 +244,7 @@ import { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOv
import { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel';
import { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus';
import { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton';
import { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader';
import { default as ToggleButton } from './classes/ToggleButton';
import { default as ToggleMenuServiceItem } from './classes/ToggleMenuServiceItem';
import { default as Tooltip } from './classes/Tooltip';
@@ -508,6 +509,7 @@ const map: Record<string, YTNodeConstructor> = {
ThumbnailOverlaySidePanel,
ThumbnailOverlayTimeStatus,
ThumbnailOverlayToggleButton,
TitleAndButtonListHeader,
ToggleButton,
ToggleMenuServiceItem,
Tooltip,

View File

@@ -1,15 +1,17 @@
import Actions from '../../core/Actions';
import Feed from '../../core/Feed';
import { observe, ObservedArray, YTNode } from '../helpers';
import { InnertubeError } from '../../utils/Utils';
import HorizontalCardList from '../classes/HorizontalCardList';
import Feed from '../../core/Feed';
import SectionList from '../classes/SectionList';
import ItemSection from '../classes/ItemSection';
import HorizontalCardList from '../classes/HorizontalCardList';
import RichListHeader from '../classes/RichListHeader';
import SearchRefinementCard from '../classes/SearchRefinementCard';
import TwoColumnSearchResults from '../classes/TwoColumnSearchResults';
import UniversalWatchCard from '../classes/UniversalWatchCard';
import WatchCardHeroVideo from '../classes/WatchCardHeroVideo';
import WatchCardSectionSequence from '../classes/WatchCardSectionSequence';
import { observe, ObservedArray, YTNode } from '../helpers';
class Search extends Feed {
results: ObservedArray<YTNode> | null | undefined;
@@ -22,11 +24,11 @@ class Search extends Feed {
super(actions, data, already_parsed);
const contents =
this.page.contents.item().as(TwoColumnSearchResults).primary_contents.item().key('contents').parsed().array() ||
this.page.contents?.item().as(TwoColumnSearchResults).primary_contents.item().as(SectionList).contents.array() ||
this.page.on_response_received_commands?.[0].contents;
const secondary_contents_maybe = this.page.contents.item().key('secondary_contents');
const secondary_contents = secondary_contents_maybe.isParsed() ? secondary_contents_maybe.parsed().item().key('contents').parsed().array() : undefined;
const secondary_contents_maybe = this.page.contents?.item().key('secondary_contents');
const secondary_contents = secondary_contents_maybe?.isParsed() ? secondary_contents_maybe.parsed().item().key('contents').parsed().array() : undefined;
this.results = contents.firstOfType(ItemSection)?.contents;
@@ -64,7 +66,7 @@ class Search extends Feed {
throw new InnertubeError('Invalid refinement card!');
}
const page = await target_card.endpoint.call(this.actions);
const page = await target_card.endpoint.call(this.actions, undefined, true);
return new Search(this.actions, page, true);
}
@@ -81,4 +83,5 @@ class Search extends Feed {
return new Search(this.actions, continuation, true);
}
}
export default Search;
export default Search;

View File

@@ -7,6 +7,7 @@ import MusicCarouselShelf from '../classes/MusicCarouselShelf';
import MusicPlaylistShelf from '../classes/MusicPlaylistShelf';
import MusicImmersiveHeader from '../classes/MusicImmersiveHeader';
import MusicVisualHeader from '../classes/MusicVisualHeader';
import MusicHeader from '../classes/MusicHeader';
class Artist {
#page;
@@ -19,7 +20,7 @@ class Artist {
this.#page = Parser.parseResponse((response as AxioslikeResponse).data);
this.#actions = actions;
this.header = this.page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
this.header = this.page.header.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader);
const music_shelf = this.#page.contents_memo.get('MusicShelf') as MusicShelf[] || [];
const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf') as MusicCarouselShelf[] || [];

View File

@@ -20,6 +20,16 @@ describe('YouTube.js Tests', () => {
expect(search.has_continuation).toBe(true);
});
it('should retrieve YouTube search continuation', async () => {
const search = await yt.search(VIDEOS[0].QUERY);
const next = await search.getContinuation()
expect(next.results?.length).toBeLessThanOrEqual(35);
expect(next.videos.length).toBeLessThanOrEqual(35);
expect(next.playlists.length).toBeLessThanOrEqual(35);
expect(next.channels.length).toBeLessThanOrEqual(35);
expect(next.has_continuation).toBe(true);
});
it('should search on YouTube Music', async () => {
const search = await yt.music.search(VIDEOS[1].QUERY);
expect(search.songs?.contents.length).toBeLessThanOrEqual(3);