Skip to content

Commit

Permalink
feat: add support for hashtag feeds (#312)
Browse files Browse the repository at this point in the history
* feat: add hashtag params proto

* feat: add support for hashtags

* chore: add test

* docs: update API ref

* fix(tests): remove unneeded `#` from param

* fix: do not throw when missing the header
  • Loading branch information
LuanRT authored Feb 16, 2023
1 parent 0d77b59 commit bf12740
Show file tree
Hide file tree
Showing 12 changed files with 299 additions and 0 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ const yt = await Innertube.create({
* [.getNotifications()](#getnotifications)
* [.getUnseenNotificationsCount()](#getunseennotificationscount)
* [.getPlaylist(id)](#getplaylist)
* [.getHashtag(hashtag)](#gethashtag)
* [.getStreamingData(video_id, options)](#getstreamingdata)
* [.download(video_id, options?)](#download)
* [.resolveURL(url)](#resolveurl)
Expand Down Expand Up @@ -538,6 +539,28 @@ Retrieves playlist contents.
</p>
</details>

<a name="gethashtag"></a>
### getHashtag(hashtag)
Retrieves a given hashtag's page.

**Returns**: `Promise.<HashtagFeed>`

| Param | Type | Description |
| --- | --- | --- |
| hashtag | `string` | The hashtag |

<details>
<summary>Methods & Getter</summary>
<p>

- `<hashtag>#applyFilter(filter)`
- Applies given filter and returns a new `HashtagFeed` instance.
- `<hashtag>#getContinuation()`
- Retrieves next batch of contents.

</p>
</details>

<a name="getstreamingdata"></a>
### getStreamingData(video_id, options)
Returns deciphered streaming data.
Expand Down
14 changes: 14 additions & 0 deletions src/Innertube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NotificationsMenu from './parser/youtube/NotificationsMenu.js';
import Playlist from './parser/youtube/Playlist.js';
import Search from './parser/youtube/Search.js';
import VideoInfo from './parser/youtube/VideoInfo.js';
import HashtagFeed from './parser/youtube/HashtagFeed.js';

import AccountManager from './core/AccountManager.js';
import Feed from './core/Feed.js';
Expand Down Expand Up @@ -245,6 +246,19 @@ class Innertube {
return new Playlist(this.actions, response);
}

/**
* Retrieves a given hashtag's page.
* @param hashtag - The hashtag to fetch.
*/
async getHashtag(hashtag: string): Promise<HashtagFeed> {
throwIfMissing({ hashtag });

const params = Proto.encodeHashtag(hashtag);
const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params });

return new HashtagFeed(this.actions, response);
}

/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
Expand Down
18 changes: 18 additions & 0 deletions src/parser/classes/HashtagHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';
import type { RawNode } from '../index.js';

class HashtagHeader extends YTNode {
static type = 'HashtagHeader';

hashtag: Text;
hashtag_info: Text;

constructor(data: RawNode) {
super();
this.hashtag = new Text(data.hashtag);
this.hashtag_info = new Text(data.hashtagInfoText);
}
}

export default HashtagHeader;
2 changes: 2 additions & 0 deletions src/parser/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { default as GridChannel } from './classes/GridChannel.js';
import { default as GridHeader } from './classes/GridHeader.js';
import { default as GridPlaylist } from './classes/GridPlaylist.js';
import { default as GridVideo } from './classes/GridVideo.js';
import { default as HashtagHeader } from './classes/HashtagHeader.js';
import { default as Heatmap } from './classes/Heatmap.js';
import { default as HeatMarker } from './classes/HeatMarker.js';
import { default as HighlightsCarousel } from './classes/HighlightsCarousel.js';
Expand Down Expand Up @@ -428,6 +429,7 @@ export const YTNodes = {
GridHeader,
GridPlaylist,
GridVideo,
HashtagHeader,
Heatmap,
HeatMarker,
HighlightsCarousel,
Expand Down
42 changes: 42 additions & 0 deletions src/parser/youtube/HashtagFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import FilterableFeed from '../../core/FilterableFeed.js';
import { InnertubeError } from '../../utils/Utils.js';
import HashtagHeader from '../classes/HashtagHeader.js';
import RichGrid from '../classes/RichGrid.js';
import Tab from '../classes/Tab.js';

import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type ChipCloudChip from '../classes/ChipCloudChip.js';
import type { IBrowseResponse } from '../index.js';

export default class HashtagFeed extends FilterableFeed<IBrowseResponse> {
header?: HashtagHeader;
contents: RichGrid;

constructor(actions: Actions, response: IBrowseResponse | ApiResponse) {
super(actions, response);

if (!this.page.contents_memo)
throw new InnertubeError('Unexpected response', this.page);

const tab = this.page.contents_memo.getType(Tab).first();

if (!tab.content)
throw new InnertubeError('Content tab has no content', tab);

if (this.page.header) {
this.header = this.page.header.item().as(HashtagHeader);
}

this.contents = tab.content.as(RichGrid);
}

/**
* Applies given filter and returns a new {@link HashtagFeed} object. Use {@link HashtagFeed.filters} to get available filters.
* @param filter - Filter to apply.
*/
async applyFilter(filter: string | ChipCloudChip): Promise<HashtagFeed> {
const response = await super.getFilteredFeed(filter);
return new HashtagFeed(this.actions, response.page);
}
}
92 changes: 92 additions & 0 deletions src/proto/generated/messages/youtube/(Hashtag)/Params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
tsValueToJsonValueFns,
jsonValueToTsValueFns,
} from "../../../runtime/json/scalar.js";
import {
WireMessage,
} from "../../../runtime/wire/index.js";
import {
default as serialize,
} from "../../../runtime/wire/serialize.js";
import {
tsValueToWireValueFns,
wireValueToTsValueFns,
} from "../../../runtime/wire/scalar.js";
import {
default as deserialize,
} from "../../../runtime/wire/deserialize.js";

export declare namespace $.youtube.Hashtag {
export type Params = {
hashtag: string;
type: number;
}
}

export type Type = $.youtube.Hashtag.Params;

export function getDefaultValue(): $.youtube.Hashtag.Params {
return {
hashtag: "",
type: 0,
};
}

export function createValue(partialValue: Partial<$.youtube.Hashtag.Params>): $.youtube.Hashtag.Params {
return {
...getDefaultValue(),
...partialValue,
};
}

export function encodeJson(value: $.youtube.Hashtag.Params): unknown {
const result: any = {};
if (value.hashtag !== undefined) result.hashtag = tsValueToJsonValueFns.string(value.hashtag);
if (value.type !== undefined) result.type = tsValueToJsonValueFns.int32(value.type);
return result;
}

export function decodeJson(value: any): $.youtube.Hashtag.Params {
const result = getDefaultValue();
if (value.hashtag !== undefined) result.hashtag = jsonValueToTsValueFns.string(value.hashtag);
if (value.type !== undefined) result.type = jsonValueToTsValueFns.int32(value.type);
return result;
}

export function encodeBinary(value: $.youtube.Hashtag.Params): Uint8Array {
const result: WireMessage = [];
if (value.hashtag !== undefined) {
const tsValue = value.hashtag;
result.push(
[1, tsValueToWireValueFns.string(tsValue)],
);
}
if (value.type !== undefined) {
const tsValue = value.type;
result.push(
[3, tsValueToWireValueFns.int32(tsValue)],
);
}
return serialize(result);
}

export function decodeBinary(binary: Uint8Array): $.youtube.Hashtag.Params {
const result = getDefaultValue();
const wireMessage = deserialize(binary);
const wireFields = new Map(wireMessage);
field: {
const wireValue = wireFields.get(1);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.string(wireValue);
if (value === undefined) break field;
result.hashtag = value;
}
field: {
const wireValue = wireFields.get(3);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.type = value;
}
return result;
}
1 change: 1 addition & 0 deletions src/proto/generated/messages/youtube/(Hashtag)/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { Type as Params } from "./Params.js";
78 changes: 78 additions & 0 deletions src/proto/generated/messages/youtube/Hashtag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
Type as Params,
encodeJson as encodeJson_1,
decodeJson as decodeJson_1,
encodeBinary as encodeBinary_1,
decodeBinary as decodeBinary_1,
} from "./(Hashtag)/Params.js";
import {
jsonValueToTsValueFns,
} from "../../runtime/json/scalar.js";
import {
WireMessage,
WireType,
} from "../../runtime/wire/index.js";
import {
default as serialize,
} from "../../runtime/wire/serialize.js";
import {
default as deserialize,
} from "../../runtime/wire/deserialize.js";

export declare namespace $.youtube {
export type Hashtag = {
params?: Params;
}
}

export type Type = $.youtube.Hashtag;

export function getDefaultValue(): $.youtube.Hashtag {
return {
params: undefined,
};
}

export function createValue(partialValue: Partial<$.youtube.Hashtag>): $.youtube.Hashtag {
return {
...getDefaultValue(),
...partialValue,
};
}

export function encodeJson(value: $.youtube.Hashtag): unknown {
const result: any = {};
if (value.params !== undefined) result.params = encodeJson_1(value.params);
return result;
}

export function decodeJson(value: any): $.youtube.Hashtag {
const result = getDefaultValue();
if (value.params !== undefined) result.params = decodeJson_1(value.params);
return result;
}

export function encodeBinary(value: $.youtube.Hashtag): Uint8Array {
const result: WireMessage = [];
if (value.params !== undefined) {
const tsValue = value.params;
result.push(
[93, { type: WireType.LengthDelimited as const, value: encodeBinary_1(tsValue) }],
);
}
return serialize(result);
}

export function decodeBinary(binary: Uint8Array): $.youtube.Hashtag {
const result = getDefaultValue();
const wireMessage = deserialize(binary);
const wireFields = new Map(wireMessage);
field: {
const wireValue = wireFields.get(93);
if (wireValue === undefined) break field;
const value = wireValue.type === WireType.LengthDelimited ? decodeBinary_1(wireValue.value) : undefined;
if (value === undefined) break field;
result.params = value;
}
return result;
}
1 change: 1 addition & 0 deletions src/proto/generated/messages/youtube/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export type { Type as CreateCommentParams } from "./CreateCommentParams.js";
export type { Type as PeformCommentActionParams } from "./PeformCommentActionParams.js";
export type { Type as MusicSearchFilter } from "./MusicSearchFilter.js";
export type { Type as SearchFilter } from "./SearchFilter.js";
export type { Type as Hashtag } from "./Hashtag.js";
12 changes: 12 additions & 0 deletions src/proto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as CreateCommentParams from './generated/messages/youtube/CreateComment
import * as PeformCommentActionParams from './generated/messages/youtube/PeformCommentActionParams.js';
import * as NotificationPreferences from './generated/messages/youtube/NotificationPreferences.js';
import * as InnertubePayload from './generated/messages/youtube/InnertubePayload.js';
import * as Hashtag from './generated/messages/youtube/Hashtag.js';

class Proto {
static encodeVisitorData(id: string, timestamp: number): string {
Expand Down Expand Up @@ -313,6 +314,17 @@ class Proto {

return buf;
}

static encodeHashtag(hashtag: string): string {
const buf = Hashtag.encodeBinary({
params: {
hashtag,
type: 1
}
});

return encodeURIComponent(u8ToBase64(buf));
}
}

export default Proto;
9 changes: 9 additions & 0 deletions src/proto/youtube.proto
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,13 @@ message SearchFilter {
}

optional Filters filters = 2;
}

message Hashtag {
message Params {
required string hashtag = 1;
required int32 type = 3;
}

required Params params = 93;
}
7 changes: 7 additions & 0 deletions test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,13 @@ describe('YouTube.js Tests', () => {
expect(channel.has_search).toBe(false);
})

it('should retrieve hashtags', async () => {
const hashtag = await yt.getHashtag('music');
expect(hashtag.header).toBeDefined();
expect(hashtag.contents).toBeDefined();
expect(hashtag.videos.length).toBeGreaterThan(0);
});

it('should retrieve home feed', async () => {
const homefeed = await yt.getHomeFeed();
expect(homefeed.header).toBeDefined();
Expand Down

0 comments on commit bf12740

Please sign in to comment.