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();