From bf12740333a82c26fe84e7c702c2fbb8859814fc Mon Sep 17 00:00:00 2001
From: LuanRT
Date: Thu, 16 Feb 2023 06:46:20 -0300
Subject: [PATCH] feat: add support for hashtag feeds (#312)
* 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
---
README.md | 23 +++++
src/Innertube.ts | 14 +++
src/parser/classes/HashtagHeader.ts | 18 ++++
src/parser/map.ts | 2 +
src/parser/youtube/HashtagFeed.ts | 42 +++++++++
.../messages/youtube/(Hashtag)/Params.ts | 92 +++++++++++++++++++
.../messages/youtube/(Hashtag)/index.ts | 1 +
.../generated/messages/youtube/Hashtag.ts | 78 ++++++++++++++++
src/proto/generated/messages/youtube/index.ts | 1 +
src/proto/index.ts | 12 +++
src/proto/youtube.proto | 9 ++
test/main.test.ts | 7 ++
12 files changed, 299 insertions(+)
create mode 100644 src/parser/classes/HashtagHeader.ts
create mode 100644 src/parser/youtube/HashtagFeed.ts
create mode 100644 src/proto/generated/messages/youtube/(Hashtag)/Params.ts
create mode 100644 src/proto/generated/messages/youtube/(Hashtag)/index.ts
create mode 100644 src/proto/generated/messages/youtube/Hashtag.ts
diff --git a/README.md b/README.md
index d6646de82..53ed01cc0 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -538,6 +539,28 @@ Retrieves playlist contents.
+
+### getHashtag(hashtag)
+Retrieves a given hashtag's page.
+
+**Returns**: `Promise.`
+
+| Param | Type | Description |
+| --- | --- | --- |
+| hashtag | `string` | The hashtag |
+
+
+Methods & Getter
+
+
+- `#applyFilter(filter)`
+ - Applies given filter and returns a new `HashtagFeed` instance.
+- `#getContinuation()`
+ - Retrieves next batch of contents.
+
+
+
+
### getStreamingData(video_id, options)
Returns deciphered streaming data.
diff --git a/src/Innertube.ts b/src/Innertube.ts
index c33c3b692..6bb0ac3e2 100644
--- a/src/Innertube.ts
+++ b/src/Innertube.ts
@@ -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';
@@ -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 {
+ 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.
diff --git a/src/parser/classes/HashtagHeader.ts b/src/parser/classes/HashtagHeader.ts
new file mode 100644
index 000000000..1c8054692
--- /dev/null
+++ b/src/parser/classes/HashtagHeader.ts
@@ -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;
\ No newline at end of file
diff --git a/src/parser/map.ts b/src/parser/map.ts
index 26242c502..da1fe0455 100644
--- a/src/parser/map.ts
+++ b/src/parser/map.ts
@@ -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';
@@ -428,6 +429,7 @@ export const YTNodes = {
GridHeader,
GridPlaylist,
GridVideo,
+ HashtagHeader,
Heatmap,
HeatMarker,
HighlightsCarousel,
diff --git a/src/parser/youtube/HashtagFeed.ts b/src/parser/youtube/HashtagFeed.ts
new file mode 100644
index 000000000..18a5b1253
--- /dev/null
+++ b/src/parser/youtube/HashtagFeed.ts
@@ -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 {
+ 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 {
+ const response = await super.getFilteredFeed(filter);
+ return new HashtagFeed(this.actions, response.page);
+ }
+}
\ No newline at end of file
diff --git a/src/proto/generated/messages/youtube/(Hashtag)/Params.ts b/src/proto/generated/messages/youtube/(Hashtag)/Params.ts
new file mode 100644
index 000000000..ec92ba22f
--- /dev/null
+++ b/src/proto/generated/messages/youtube/(Hashtag)/Params.ts
@@ -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;
+}
diff --git a/src/proto/generated/messages/youtube/(Hashtag)/index.ts b/src/proto/generated/messages/youtube/(Hashtag)/index.ts
new file mode 100644
index 000000000..fdd997fd6
--- /dev/null
+++ b/src/proto/generated/messages/youtube/(Hashtag)/index.ts
@@ -0,0 +1 @@
+export type { Type as Params } from "./Params.js";
diff --git a/src/proto/generated/messages/youtube/Hashtag.ts b/src/proto/generated/messages/youtube/Hashtag.ts
new file mode 100644
index 000000000..81dbafd21
--- /dev/null
+++ b/src/proto/generated/messages/youtube/Hashtag.ts
@@ -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;
+}
diff --git a/src/proto/generated/messages/youtube/index.ts b/src/proto/generated/messages/youtube/index.ts
index ea4ec6161..c22453b19 100644
--- a/src/proto/generated/messages/youtube/index.ts
+++ b/src/proto/generated/messages/youtube/index.ts
@@ -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";
diff --git a/src/proto/index.ts b/src/proto/index.ts
index 95013fd5f..f4be3f612 100644
--- a/src/proto/index.ts
+++ b/src/proto/index.ts
@@ -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 {
@@ -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;
\ No newline at end of file
diff --git a/src/proto/youtube.proto b/src/proto/youtube.proto
index c14e168ff..9b97ed3bf 100644
--- a/src/proto/youtube.proto
+++ b/src/proto/youtube.proto
@@ -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;
}
\ No newline at end of file
diff --git a/test/main.test.ts b/test/main.test.ts
index 1d07a9700..0f76182be 100644
--- a/test/main.test.ts
+++ b/test/main.test.ts
@@ -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();