From 95e0294eabfdb20bbee2a4bfb751fd101402c5d6 Mon Sep 17 00:00:00 2001 From: LuanRT Date: Fri, 28 Apr 2023 19:01:04 -0300 Subject: [PATCH] refactor!: overhaul core classes and remove redundant code (#388) * feat(Player.ts): append `cver` to deciphered URLs * refactor(Actions.ts): remove redundant `getVideoInfo` function This is leftover code from previous versions. It had many problems and it is no longer required. * fix(Kids.ts): remove unneeded `await` keywords * dev: add more endpoints * chore: update deps * refactor: separate endpoints into files * dev: improve types * dev: add more endpoints * refactor: put clients in a separate directory inside `core` * chore: lint * refactor: move mixins and managers to separate folders * chore: fix tests * dev: add `CreateVideoEndpoint` * chore: clean up * chore: lint * chore: add some comments * chore: remove unnecessary test * dev: add `playlist/CreateEndpoint` * dev: add `playlist/DeleteEndpoint` * dev: add `browse/EditPlaylistEndpoint` * fix(parser): add a few checks to avoid parsing errors --- package-lock.json | 16 +- package.json | 2 +- src/Innertube.ts | 231 ++++++------ src/core/Actions.ts | 57 +-- src/core/Kids.ts | 68 ---- src/core/OAuth.ts | 6 +- src/core/Player.ts | 26 ++ src/core/Session.ts | 20 +- src/core/clients/Kids.ts | 83 +++++ src/core/{ => clients}/Music.ts | 239 ++++++------ src/core/{ => clients}/Studio.ts | 95 ++--- src/core/clients/index.ts | 3 + src/core/endpoints/BrowseEndpoint.ts | 19 + .../endpoints/GetNotificationMenuEndpoint.ts | 16 + src/core/endpoints/GuideEndpoint.ts | 1 + src/core/endpoints/NextEndpoint.ts | 21 ++ src/core/endpoints/PlayerEndpoint.ts | 39 ++ src/core/endpoints/ResolveURLEndpoint.ts | 16 + src/core/endpoints/SearchEndpoint.ts | 19 + .../endpoints/account/AccountListEndpoint.ts | 13 + src/core/endpoints/account/index.ts | 1 + .../endpoints/browse/EditPlaylistEndpoint.ts | 22 ++ src/core/endpoints/browse/index.ts | 1 + .../channel/EditDescriptionEndpoint.ts | 15 + .../endpoints/channel/EditNameEndpoint.ts | 15 + src/core/endpoints/channel/index.ts | 2 + .../comment/CreateCommentEndpoint.ts | 18 + .../comment/PerformCommentActionEndpoint.ts | 17 + src/core/endpoints/comment/index.ts | 2 + src/core/endpoints/index.ts | 18 + src/core/endpoints/like/DislikeEndpoint.ts | 19 + src/core/endpoints/like/LikeEndpoint.ts | 19 + src/core/endpoints/like/RemoveLikeEndpoint.ts | 19 + src/core/endpoints/like/index.ts | 3 + .../music/GetSearchSuggestionsEndpoint.ts | 16 + src/core/endpoints/music/index.ts | 1 + .../notification/GetUnseenCountEndpoint.ts | 1 + .../ModifyChannelPreferenceEndpoint.ts | 17 + src/core/endpoints/notification/index.ts | 2 + src/core/endpoints/playlist/CreateEndpoint.ts | 15 + src/core/endpoints/playlist/DeleteEndpoint.ts | 14 + src/core/endpoints/playlist/index.ts | 2 + .../subscription/SubscribeEndpoint.ts | 18 + .../subscription/UnsubscribeEndpoint.ts | 18 + src/core/endpoints/subscription/index.ts | 2 + .../endpoints/upload/CreateVideoEndpoint.ts | 37 ++ src/core/endpoints/upload/index.ts | 1 + src/core/index.ts | 38 +- src/core/{ => managers}/AccountManager.ts | 82 +++-- src/core/{ => managers}/InteractionManager.ts | 113 +++--- src/core/{ => managers}/PlaylistManager.ts | 100 +++-- src/core/managers/index.ts | 3 + src/core/{ => mixins}/Feed.ts | 68 ++-- src/core/{ => mixins}/FilterableFeed.ts | 20 +- src/core/{ => mixins}/MediaInfo.ts | 14 +- src/core/{ => mixins}/TabbedFeed.ts | 18 +- src/core/mixins/index.ts | 4 + src/parser/classes/InfoPanelContent.ts | 2 +- src/parser/classes/MultiMarkersPlayerBar.ts | 12 +- .../analytics/AnalyticsVodCarouselCard.ts | 2 +- src/parser/classes/comments/CreatorHeart.ts | 2 +- src/parser/classes/comments/PdgCommentChip.ts | 2 +- .../classes/menus/MusicMultiSelectMenu.ts | 2 +- src/parser/classes/misc/ChildElement.ts | 2 +- src/parser/youtube/Channel.ts | 6 +- src/parser/youtube/HashtagFeed.ts | 2 +- src/parser/youtube/History.ts | 2 +- src/parser/youtube/HomeFeed.ts | 2 +- src/parser/youtube/Library.ts | 2 +- src/parser/youtube/Playlist.ts | 2 +- src/parser/youtube/Search.ts | 2 +- src/parser/youtube/VideoInfo.ts | 2 +- src/parser/ytkids/Channel.ts | 2 +- src/parser/ytkids/HomeFeed.ts | 2 +- src/parser/ytkids/Search.ts | 2 +- src/parser/ytkids/VideoInfo.ts | 2 +- src/parser/ytmusic/TrackInfo.ts | 2 +- src/proto/index.ts | 4 +- src/types/Clients.ts | 23 ++ src/types/Endpoints.ts | 343 ++++++++++++++++++ src/types/index.ts | 6 +- src/utils/HTTPClient.ts | 25 +- test/main.test.ts | 9 +- 83 files changed, 1524 insertions(+), 705 deletions(-) delete mode 100644 src/core/Kids.ts create mode 100644 src/core/clients/Kids.ts rename src/core/{ => clients}/Music.ts (59%) rename src/core/{ => clients}/Studio.ts (73%) create mode 100644 src/core/clients/index.ts create mode 100644 src/core/endpoints/BrowseEndpoint.ts create mode 100644 src/core/endpoints/GetNotificationMenuEndpoint.ts create mode 100644 src/core/endpoints/GuideEndpoint.ts create mode 100644 src/core/endpoints/NextEndpoint.ts create mode 100644 src/core/endpoints/PlayerEndpoint.ts create mode 100644 src/core/endpoints/ResolveURLEndpoint.ts create mode 100644 src/core/endpoints/SearchEndpoint.ts create mode 100644 src/core/endpoints/account/AccountListEndpoint.ts create mode 100644 src/core/endpoints/account/index.ts create mode 100644 src/core/endpoints/browse/EditPlaylistEndpoint.ts create mode 100644 src/core/endpoints/browse/index.ts create mode 100644 src/core/endpoints/channel/EditDescriptionEndpoint.ts create mode 100644 src/core/endpoints/channel/EditNameEndpoint.ts create mode 100644 src/core/endpoints/channel/index.ts create mode 100644 src/core/endpoints/comment/CreateCommentEndpoint.ts create mode 100644 src/core/endpoints/comment/PerformCommentActionEndpoint.ts create mode 100644 src/core/endpoints/comment/index.ts create mode 100644 src/core/endpoints/index.ts create mode 100644 src/core/endpoints/like/DislikeEndpoint.ts create mode 100644 src/core/endpoints/like/LikeEndpoint.ts create mode 100644 src/core/endpoints/like/RemoveLikeEndpoint.ts create mode 100644 src/core/endpoints/like/index.ts create mode 100644 src/core/endpoints/music/GetSearchSuggestionsEndpoint.ts create mode 100644 src/core/endpoints/music/index.ts create mode 100644 src/core/endpoints/notification/GetUnseenCountEndpoint.ts create mode 100644 src/core/endpoints/notification/ModifyChannelPreferenceEndpoint.ts create mode 100644 src/core/endpoints/notification/index.ts create mode 100644 src/core/endpoints/playlist/CreateEndpoint.ts create mode 100644 src/core/endpoints/playlist/DeleteEndpoint.ts create mode 100644 src/core/endpoints/playlist/index.ts create mode 100644 src/core/endpoints/subscription/SubscribeEndpoint.ts create mode 100644 src/core/endpoints/subscription/UnsubscribeEndpoint.ts create mode 100644 src/core/endpoints/subscription/index.ts create mode 100644 src/core/endpoints/upload/CreateVideoEndpoint.ts create mode 100644 src/core/endpoints/upload/index.ts rename src/core/{ => managers}/AccountManager.ts (50%) rename src/core/{ => managers}/InteractionManager.ts (59%) rename src/core/{ => managers}/PlaylistManager.ts (71%) create mode 100644 src/core/managers/index.ts rename src/core/{ => mixins}/Feed.ts (68%) rename src/core/{ => mixins}/FilterableFeed.ts (78%) rename src/core/{ => mixins}/MediaInfo.ts (87%) rename src/core/{ => mixins}/TabbedFeed.ts (78%) create mode 100644 src/core/mixins/index.ts create mode 100644 src/types/Clients.ts create mode 100644 src/types/Endpoints.ts diff --git a/package-lock.json b/package-lock.json index fa52aca6..f9245255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "pbkit": "^0.0.59", "replace": "^1.2.2", "ts-jest": "^28.0.8", - "typescript": "^4.9.5" + "typescript": "^5.0.0" } }, "node_modules/@ampproject/remapping": { @@ -6265,16 +6265,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/uhyphen": { @@ -11007,9 +11007,9 @@ "dev": true }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, "uhyphen": { diff --git a/package.json b/package.json index de025ad9..0d713d52 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "pbkit": "^0.0.59", "replace": "^1.2.2", "ts-jest": "^28.0.8", - "typescript": "^4.9.5" + "typescript": "^5.0.0" }, "bugs": { "url": "https://github.com/LuanRT/YouTube.js/issues" diff --git a/src/Innertube.ts b/src/Innertube.ts index a46e4d4b..4cc07263 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -1,50 +1,56 @@ - import Session, { SessionOptions } from './core/Session.js'; import NavigationEndpoint from './parser/classes/NavigationEndpoint.js'; +import type Format from './parser/classes/misc/Format.js'; import Channel from './parser/youtube/Channel.js'; import Comments from './parser/youtube/Comments.js'; +import Guide from './parser/youtube/Guide.js'; +import HashtagFeed from './parser/youtube/HashtagFeed.js'; import History from './parser/youtube/History.js'; +import HomeFeed from './parser/youtube/HomeFeed.js'; import Library from './parser/youtube/Library.js'; 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'; -import InteractionManager from './core/InteractionManager.js'; -import YTKids from './core/Kids.js'; -import YTMusic from './core/Music.js'; -import PlaylistManager from './core/PlaylistManager.js'; -import YTStudio from './core/Studio.js'; -import TabbedFeed from './core/TabbedFeed.js'; -import HomeFeed from './parser/youtube/HomeFeed.js'; -import Guide from './parser/youtube/Guide.js'; +import { Kids, Music, Studio } from './core/clients/index.js'; +import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js'; +import { Feed, TabbedFeed } from './core/mixins/index.js'; + import Proto from './proto/index.js'; import Constants from './utils/Constants.js'; +import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js'; -import type Actions from './core/Actions.js'; -import type Format from './parser/classes/misc/Format.js'; +import { + BrowseEndpoint, + GetNotificationMenuEndpoint, + GuideEndpoint, + NextEndpoint, + PlayerEndpoint, + ResolveURLEndpoint, + SearchEndpoint +} from './core/endpoints/index.js'; + +import { GetUnseenCountEndpoint } from './core/endpoints/notification/index.js'; import type { ApiResponse } from './core/Actions.js'; import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js'; +import type { INextRequest } from './types/index.js'; import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.js'; -import { generateRandomString, InnertubeError, throwIfMissing } from './utils/Utils.js'; export type InnertubeConfig = SessionOptions; -export interface SearchFilters { - upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year'; - type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie'; - duration?: 'all' | 'short' | 'medium' | 'long'; - sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'; - features?: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[]; -} - export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS' +export type SearchFilters = Partial<{ + upload_date: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year'; + type: 'all' | 'video' | 'channel' | 'playlist' | 'movie'; + duration: 'all' | 'short' | 'medium' | 'long'; + sort_by: 'relevance' | 'rating' | 'upload_date' | 'view_count'; + features: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[]; +}>; + /** * Provides access to various services and modules in the YouTube API. */ @@ -67,48 +73,39 @@ export default class Innertube { async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise { throwIfMissing({ target }); - let payload: { - videoId: string, - playlistId?: string, - params?: string, - playlistIndex?: number - }; + let next_payload: INextRequest; if (target instanceof NavigationEndpoint) { - const video_id = target.payload?.videoId; - - if (!video_id) - throw new InnertubeError('Missing video id in endpoint payload.', target); - - payload = { - videoId: video_id - }; - - if (target.payload.playlistId) { - payload.playlistId = target.payload.playlistId; - } - - if (target.payload.params) { - payload.params = target.payload.params; - } - - if (target.payload.index) { - payload.playlistIndex = target.payload.index; - } + next_payload = NextEndpoint.build({ + video_id: target.payload?.videoId, + playlist_id: target.payload?.playlistId, + params: target.payload?.params, + playlist_index: target.payload?.index + }); } else if (typeof target === 'string') { - payload = { - videoId: target - }; + next_payload = NextEndpoint.build({ + video_id: target + }); } else { throw new InnertubeError('Invalid target, expected either a video id or a valid NavigationEndpoint', target); } + if (!next_payload.videoId) + throw new InnertubeError('Video id cannot be empty', next_payload); + + const player_payload = PlayerEndpoint.build({ + video_id: next_payload.videoId, + playlist_id: next_payload?.playlistId, + client: client, + sts: this.#session.player?.sts + }); + + const player_response = this.actions.execute(PlayerEndpoint.PATH, player_payload); + const next_response = this.actions.execute(NextEndpoint.PATH, next_payload); + const response = await Promise.all([ player_response, next_response ]); + const cpn = generateRandomString(16); - const initial_info = this.actions.getVideoInfo(payload.videoId, cpn, client); - const continuation = this.actions.execute('/next', payload); - - const response = await Promise.all([ initial_info, continuation ]); return new VideoInfo(response, this.actions, cpn); } @@ -120,8 +117,15 @@ export default class Innertube { async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise { throwIfMissing({ video_id }); + const response = await this.actions.execute( + PlayerEndpoint.PATH, PlayerEndpoint.build({ + video_id: video_id, + client: client, + sts: this.#session.player?.sts + }) + ); + const cpn = generateRandomString(16); - const response = await this.actions.getVideoInfo(video_id, cpn, client); return new VideoInfo([ response ], this.actions, cpn); } @@ -134,14 +138,11 @@ export default class Innertube { async search(query: string, filters: SearchFilters = {}): Promise { throwIfMissing({ query }); - const args = { - query, - ...{ - params: filters ? Proto.encodeSearchFilters(filters) : undefined - } - }; - - const response = await this.actions.execute('/search', args); + const response = await this.actions.execute( + SearchEndpoint.PATH, SearchEndpoint.build({ + query, params: filters ? Proto.encodeSearchFilters(filters) : undefined + }) + ); return new Search(this.actions, response); } @@ -179,11 +180,13 @@ export default class Innertube { async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise { throwIfMissing({ video_id }); - const payload = Proto.encodeCommentsSectionParams(video_id, { - sort_by: sort_by || 'TOP_COMMENTS' - }); - - const response = await this.actions.execute('/next', { continuation: payload }); + const response = await this.actions.execute( + NextEndpoint.PATH, NextEndpoint.build({ + continuation: Proto.encodeCommentsSectionParams(video_id, { + sort_by: sort_by || 'TOP_COMMENTS' + }) + }) + ); return new Comments(this.actions, response.data); } @@ -192,7 +195,9 @@ export default class Innertube { * Retrieves YouTube's home feed (aka recommendations). */ async getHomeFeed(): Promise { - const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEwhat_to_watch' }) + ); return new HomeFeed(this.actions, response); } @@ -200,7 +205,7 @@ export default class Innertube { * Retrieves YouTube's content guide. */ async getGuide(): Promise { - const response = await this.actions.execute('/guide'); + const response = await this.actions.execute(GuideEndpoint.PATH); return new Guide(response.data); } @@ -208,7 +213,9 @@ export default class Innertube { * Returns the account's library. */ async getLibrary(): Promise { - const response = await this.actions.execute('/browse', { browseId: 'FElibrary' }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FElibrary' }) + ); return new Library(this.actions, response); } @@ -217,7 +224,9 @@ export default class Innertube { * Which can also be achieved with {@link getLibrary}. */ async getHistory(): Promise { - const response = await this.actions.execute('/browse', { browseId: 'FEhistory' }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEhistory' }) + ); return new History(this.actions, response); } @@ -225,7 +234,9 @@ export default class Innertube { * Retrieves trending content. */ async getTrending(): Promise> { - const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEtrending' }), parse: true } + ); return new TabbedFeed(this.actions, response); } @@ -233,7 +244,9 @@ export default class Innertube { * Retrieves subscriptions feed. */ async getSubscriptionsFeed(): Promise> { - const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions', parse: true }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEsubscriptions' }), parse: true } + ); return new Feed(this.actions, response); } @@ -243,7 +256,9 @@ export default class Innertube { */ async getChannel(id: string): Promise { throwIfMissing({ id }); - const response = await this.actions.execute('/browse', { browseId: id }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id }) + ); return new Channel(this.actions, response); } @@ -251,7 +266,11 @@ export default class Innertube { * Retrieves notifications. */ async getNotifications(): Promise { - const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' }); + const response = await this.actions.execute( + GetNotificationMenuEndpoint.PATH, GetNotificationMenuEndpoint.build({ + notifications_menu_request_type: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' + }) + ); return new NotificationsMenu(this.actions, response); } @@ -259,7 +278,7 @@ export default class Innertube { * Retrieves unseen notifications count. */ async getUnseenNotificationsCount(): Promise { - const response = await this.actions.execute('/notification/get_unseen_count'); + const response = await this.actions.execute(GetUnseenCountEndpoint.PATH); // TODO: properly parse this return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0; } @@ -275,7 +294,9 @@ export default class Innertube { id = `VL${id}`; } - const response = await this.actions.execute('/browse', { browseId: id }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id }) + ); return new Playlist(this.actions, response); } @@ -287,8 +308,12 @@ export default class Innertube { async getHashtag(hashtag: string): Promise { throwIfMissing({ hashtag }); - const params = Proto.encodeHashtag(hashtag); - const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params }); + const response = await this.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'FEhashtag', + params: Proto.encodeHashtag(hashtag) + }) + ); return new HashtagFeed(this.actions, response); } @@ -322,7 +347,9 @@ export default class Innertube { * @param url - The URL. */ async resolveURL(url: string): Promise { - const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true }); + const response = await this.actions.execute( + ResolveURLEndpoint.PATH, { ...ResolveURLEndpoint.build({ url }), parse: true } + ); return response.endpoint; } @@ -338,58 +365,58 @@ export default class Innertube { } /** - * An instance of YTMusic for interacting with the YouTube Music service. + * An interface for interacting with YouTube Music. */ - get music(): YTMusic { - return new YTMusic(this.#session); + get music() { + return new Music(this.#session); } /** - * An instance of YTStudio for interacting with the YouTube Studio service. + * An interface for interacting with YouTube Studio. */ - get studio(): YTStudio { - return new YTStudio(this.#session); + get studio() { + return new Studio(this.#session); } /** - * An instance of YTKids for interacting with the YouTube Kids service. + * An interface for interacting with YouTube Kids. */ - get kids(): YTKids { - return new YTKids(this.#session); + get kids() { + return new Kids(this.#session); } /** - * An instance of AccountManager for managing a user's account. + * An interface for managing and retrieving account information. */ - get account(): AccountManager { + get account() { return new AccountManager(this.#session.actions); } /** - * An instance of PlaylistManager for managing playlists. + * An interface for managing playlists. */ - get playlist(): PlaylistManager { + get playlist() { return new PlaylistManager(this.#session.actions); } /** - * An instance of InteractionManager for interacting with contents in YouTube. + * An interface for directly interacting with certain YouTube features. */ - get interact(): InteractionManager { + get interact() { return new InteractionManager(this.#session.actions); } /** - * An instance of Actions. + * An internal class used to dispatch requests. */ - get actions(): Actions { + get actions() { return this.#session.actions; } /** - * Returns the InnerTube session instance. + * The session used by this instance. */ - get session(): Session { + get session() { return this.#session; } } \ No newline at end of file diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 00425eb5..6d741918 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -28,7 +28,7 @@ export type ParsedResponse = T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse : IParsedResponse; -class Actions { +export default class Actions { #session: Session; constructor(session: Session) { @@ -51,57 +51,6 @@ class Actions { }; } - /** - * Used to retrieve video info. - * @param id - The video ID. - * @param cpn - Content Playback Nonce. - * @param client - The client to use. - * @param playlist_id - The playlist ID. - */ - async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise { - const data: Record = { - playbackContext: { - contentPlaybackContext: { - vis: 0, - splay: false, - referer: 'https://www.youtube.com', - currentUrl: `/watch?v=${id}`, - autonavState: 'STATE_NONE', - signatureTimestamp: this.#session.player?.sts || 0, - autoCaptionsDefaultOn: false, - html5Preference: 'HTML5_PREF_WANTS', - lactMilliseconds: '-1' - } - }, - attestationRequest: { - omitBotguardData: true - }, - videoId: id - }; - - if (client) { - data.client = client; - } - - if (cpn) { - data.cpn = cpn; - } - - if (playlist_id) { - data.playlistId = playlist_id; - } - - const response = await this.#session.http.fetch('/player', { - method: 'POST', - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json' - } - }); - - return this.#wrap(response); - } - /** * Makes calls to the playback tracking API. * @param url - The URL to call. @@ -226,6 +175,4 @@ class Actions { 'SPtime_watched' ].includes(id); } -} - -export default Actions; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/Kids.ts b/src/core/Kids.ts deleted file mode 100644 index 956d4655..00000000 --- a/src/core/Kids.ts +++ /dev/null @@ -1,68 +0,0 @@ -import Search from '../parser/ytkids/Search.js'; -import HomeFeed from '../parser/ytkids/HomeFeed.js'; -import VideoInfo from '../parser/ytkids/VideoInfo.js'; -import Channel from '../parser/ytkids/Channel.js'; -import type Session from './Session.js'; - -import { generateRandomString } from '../utils/Utils.js'; - -class Kids { - #session: Session; - - constructor(session: Session) { - this.#session = session; - } - - /** - * Searches the given query. - * @param query - The query. - */ - async search(query: string): Promise { - const response = await this.#session.actions.execute('/search', { query, client: 'YTKIDS' }); - return new Search(this.#session.actions, response); - } - - /** - * Retrieves video info. - * @param video_id - The video id. - */ - async getInfo(video_id: string): Promise { - const cpn = generateRandomString(16); - - const initial_info = this.#session.actions.execute('/player', { - cpn, - client: 'YTKIDS', - videoId: video_id, - playbackContext: { - contentPlaybackContext: { - signatureTimestamp: this.#session.player?.sts || 0 - } - } - }); - - const continuation = this.#session.actions.execute('/next', { videoId: video_id, client: 'YTKIDS' }); - - const response = await Promise.all([ initial_info, continuation ]); - - return new VideoInfo(response, this.#session.actions, cpn); - } - - /** - * Retrieves the contents of the given channel. - * @param channel_id - The channel id. - */ - async getChannel(channel_id: string): Promise { - const response = await this.#session.actions.execute('/browse', { browseId: channel_id, client: 'YTKIDS' }); - return new Channel(this.#session.actions, response); - } - - /** - * Retrieves the home feed. - */ - async getHomeFeed(): Promise { - const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' }); - return new HomeFeed(this.#session.actions, response); - } -} - -export default Kids; \ No newline at end of file diff --git a/src/core/OAuth.ts b/src/core/OAuth.ts index a434e836..33920c37 100644 --- a/src/core/OAuth.ts +++ b/src/core/OAuth.ts @@ -28,7 +28,7 @@ export type OAuthAuthEventHandler = (data: { export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any; export type OAuthAuthErrorEventHandler = (err: OAuthError) => any; -class OAuth { +export default class OAuth { #identity?: Record; #session: Session; #credentials?: Credentials; @@ -264,6 +264,4 @@ class OAuth { Reflect.has(this.#credentials, 'refresh_token') && Reflect.has(this.#credentials, 'expires') || false; } -} - -export default OAuth; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/Player.ts b/src/core/Player.ts index dee72e0d..498b49f4 100644 --- a/src/core/Player.ts +++ b/src/core/Player.ts @@ -5,6 +5,9 @@ import Constants from '../utils/Constants.js'; import { ICache } from '../types/Cache.js'; import { FetchFunction } from '../types/PlatformShim.js'; +/** + * Represents YouTube's player script. This is required to decipher signatures. + */ export default class Player { #nsig_sc; #sig_sc; @@ -104,6 +107,29 @@ export default class Player { url_components.searchParams.set('n', nsig); } + const client = url_components.searchParams.get('c'); + + switch (client) { + case 'WEB': + url_components.searchParams.set('cver', Constants.CLIENTS.WEB.VERSION); + break; + case 'WEB_REMIX': + url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC.VERSION); + break; + case 'WEB_KIDS': + url_components.searchParams.set('cver', Constants.CLIENTS.WEB_KIDS.VERSION); + break; + case 'ANDROID': + url_components.searchParams.set('cver', Constants.CLIENTS.ANDROID.VERSION); + break; + case 'ANDROID_MUSIC': + url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC_ANDROID.VERSION); + break; + case 'TVHTML5_SIMPLY_EMBEDDED_PLAYER': + url_components.searchParams.set('cver', Constants.CLIENTS.TV_EMBEDDED.VERSION); + break; + } + return url_components.toString(); } diff --git a/src/core/Session.ts b/src/core/Session.ts index a79c7852..cb347c8e 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -30,7 +30,6 @@ export interface Context { screenPixelDensity: number; screenWidthPoints: number; visitorData: string; - userAgent: string; clientName: string; clientVersion: string; clientScreen?: string, @@ -41,6 +40,7 @@ export interface Context { clientFormFactor: string; userInterfaceTheme: string; timeZone: string; + userAgent?: string; browserName?: string; browserVersion?: string; originalUrl: string; @@ -64,9 +64,6 @@ export interface Context { thirdParty?: { embedUrl: string; }; - request: { - useSsl: true; - }; } export interface SessionOptions { @@ -135,6 +132,9 @@ export interface SessionData { api_version: string; } +/** + * Represents an InnerTube session. This holds all the data needed to make requests to YouTube. + */ export default class Session extends EventEmitterLike { #api_version: string; #key: string; @@ -273,7 +273,6 @@ export default class Session extends EventEmitterLike { screenPixelDensity: 1, screenWidthPoints: 1920, visitorData: device_info[13], - userAgent: device_info[14], clientName: options.client_name, clientVersion: device_info[16], osName: device_info[17], @@ -287,14 +286,11 @@ export default class Session extends EventEmitterLike { originalUrl: Constants.URLS.YT_BASE, deviceMake: device_info[11], deviceModel: device_info[12], - utcOffsetMinutes: new Date().getTimezoneOffset() + utcOffsetMinutes: -new Date().getTimezoneOffset() }, user: { enableSafetyMode: options.enable_safety_mode, lockedSafetyMode: false - }, - request: { - useSsl: true } }; @@ -326,7 +322,6 @@ export default class Session extends EventEmitterLike { screenPixelDensity: 1, screenWidthPoints: 1920, visitorData: Proto.encodeVisitorData(visitor_id, Math.floor(Date.now() / 1000)), - userAgent: getRandomUserAgent('desktop'), clientName: options.client_name, clientVersion: CLIENTS.WEB.VERSION, osName: 'Windows', @@ -338,14 +333,11 @@ export default class Session extends EventEmitterLike { originalUrl: Constants.URLS.YT_BASE, deviceMake: '', deviceModel: '', - utcOffsetMinutes: new Date().getTimezoneOffset() + utcOffsetMinutes: -new Date().getTimezoneOffset() }, user: { enableSafetyMode: options.enable_safety_mode, lockedSafetyMode: false - }, - request: { - useSsl: true } }; diff --git a/src/core/clients/Kids.ts b/src/core/clients/Kids.ts new file mode 100644 index 00000000..3f58a802 --- /dev/null +++ b/src/core/clients/Kids.ts @@ -0,0 +1,83 @@ +import Channel from '../../parser/ytkids/Channel.js'; +import HomeFeed from '../../parser/ytkids/HomeFeed.js'; +import Search from '../../parser/ytkids/Search.js'; +import VideoInfo from '../../parser/ytkids/VideoInfo.js'; +import type Session from '../Session.js'; + +import { generateRandomString } from '../../utils/Utils.js'; + +import { + BrowseEndpoint, NextEndpoint, + PlayerEndpoint, SearchEndpoint +} from '../endpoints/index.js'; + +export default class Kids { + #session: Session; + + constructor(session: Session) { + this.#session = session; + } + + /** + * Searches the given query. + * @param query - The query. + */ + async search(query: string): Promise { + const response = await this.#session.actions.execute( + SearchEndpoint.PATH, SearchEndpoint.build({ client: 'YTKIDS', query }) + ); + return new Search(this.#session.actions, response); + } + + /** + * Retrieves video info. + * @param video_id - The video id. + */ + async getInfo(video_id: string): Promise { + const player_payload = PlayerEndpoint.build({ + sts: this.#session.player?.sts, + client: 'YTKIDS', + video_id + }); + + const next_payload = NextEndpoint.build({ + video_id, + client: 'YTKIDS' + }); + + const player_response = this.#session.actions.execute(PlayerEndpoint.PATH, player_payload); + const next_response = this.#session.actions.execute(NextEndpoint.PATH, next_payload); + const response = await Promise.all([ player_response, next_response ]); + + const cpn = generateRandomString(16); + + return new VideoInfo(response, this.#session.actions, cpn); + } + + /** + * Retrieves the contents of the given channel. + * @param channel_id - The channel id. + */ + async getChannel(channel_id: string): Promise { + const response = await this.#session.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: channel_id, + client: 'YTKIDS' + }) + ); + return new Channel(this.#session.actions, response); + } + + /** + * Retrieves the home feed. + */ + async getHomeFeed(): Promise { + const response = await this.#session.actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'FEkids_home', + client: 'YTKIDS' + }) + ); + return new HomeFeed(this.#session.actions, response); + } +} \ No newline at end of file diff --git a/src/core/Music.ts b/src/core/clients/Music.ts similarity index 59% rename from src/core/Music.ts rename to src/core/clients/Music.ts index 41a50b67..eefb7a6d 100644 --- a/src/core/Music.ts +++ b/src/core/clients/Music.ts @@ -1,37 +1,41 @@ +import Album from '../../parser/ytmusic/Album.js'; +import Artist from '../../parser/ytmusic/Artist.js'; +import Explore from '../../parser/ytmusic/Explore.js'; +import HomeFeed from '../../parser/ytmusic/HomeFeed.js'; +import Library from '../../parser/ytmusic/Library.js'; +import Playlist from '../../parser/ytmusic/Playlist.js'; +import Recap from '../../parser/ytmusic/Recap.js'; +import Search from '../../parser/ytmusic/Search.js'; +import TrackInfo from '../../parser/ytmusic/TrackInfo.js'; -import Album from '../parser/ytmusic/Album.js'; -import Artist from '../parser/ytmusic/Artist.js'; -import Explore from '../parser/ytmusic/Explore.js'; -import HomeFeed from '../parser/ytmusic/HomeFeed.js'; -import Library from '../parser/ytmusic/Library.js'; -import Playlist from '../parser/ytmusic/Playlist.js'; -import Recap from '../parser/ytmusic/Recap.js'; -import Search from '../parser/ytmusic/Search.js'; -import TrackInfo from '../parser/ytmusic/TrackInfo.js'; +import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js'; +import Message from '../../parser/classes/Message.js'; +import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.js'; +import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js'; +import MusicQueue from '../../parser/classes/MusicQueue.js'; +import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js'; +import PlaylistPanel from '../../parser/classes/PlaylistPanel.js'; +import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.js'; +import SectionList from '../../parser/classes/SectionList.js'; +import Tab from '../../parser/classes/Tab.js'; +import Proto from '../../proto/index.js'; -import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo.js'; -import Message from '../parser/classes/Message.js'; -import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf.js'; -import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf.js'; -import MusicQueue from '../parser/classes/MusicQueue.js'; -import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem.js'; -import PlaylistPanel from '../parser/classes/PlaylistPanel.js'; -import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection.js'; -import SectionList from '../parser/classes/SectionList.js'; -import Tab from '../parser/classes/Tab.js'; +import type { ObservedArray, YTNode } from '../../parser/helpers.js'; +import type { MusicSearchFilters } from '../../types/index.js'; +import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.js'; +import type Actions from '../Actions.js'; +import type Session from '../Session.js'; -import Proto from '../proto/index.js'; -import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils.js'; +import { + BrowseEndpoint, + NextEndpoint, + PlayerEndpoint, + SearchEndpoint +} from '../endpoints/index.js'; -import type { ObservedArray, YTNode } from '../parser/helpers.js'; -import type Actions from './Actions.js'; -import type Session from './Session.js'; +import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.js'; -export interface MusicSearchFilters { - type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'; -} - -class Music { +export default class Music { #session: Session; #actions: Actions; @@ -55,25 +59,23 @@ class Music { } async #fetchInfoFromVideoId(video_id: string): Promise { + const player_payload = PlayerEndpoint.build({ + video_id, + sts: this.#session.player?.sts, + client: 'YTMUSIC' + }); + + const next_payload = NextEndpoint.build({ + video_id, + client: 'YTMUSIC' + }); + + const player_response = this.#actions.execute(PlayerEndpoint.PATH, player_payload); + const next_response = this.#actions.execute(NextEndpoint.PATH, next_payload); + const response = await Promise.all([ player_response, next_response ]); + const cpn = generateRandomString(16); - const initial_info = this.#actions.execute('/player', { - cpn, - client: 'YTMUSIC', - videoId: video_id, - playbackContext: { - contentPlaybackContext: { - signatureTimestamp: this.#session.player?.sts || 0 - } - } - }); - - const continuation = this.#actions.execute('/next', { - client: 'YTMUSIC', - videoId: video_id - }); - - const response = await Promise.all([ initial_info, continuation ]); return new TrackInfo(response, this.#actions, cpn); } @@ -84,25 +86,26 @@ class Music { if (!list_item.endpoint) throw new Error('This item does not have an endpoint.'); - const cpn = generateRandomString(16); - - const initial_info = list_item.endpoint.call(this.#actions, { - cpn, + const player_response = list_item.endpoint.call(this.#actions, { client: 'YTMUSIC', playbackContext: { contentPlaybackContext: { - signatureTimestamp: this.#session.player?.sts || 0 + ...{ + signatureTimestamp: this.#session.player?.sts + } } } }); - const continuation = list_item.endpoint.call(this.#actions, { + const next_response = list_item.endpoint.call(this.#actions, { client: 'YTMUSIC', enablePersistentPlaylistPanel: true, override_endpoint: '/next' }); - const response = await Promise.all([ initial_info, continuation ]); + const cpn = generateRandomString(16); + + const response = await Promise.all([ player_response, next_response ]); return new TrackInfo(response, this.#actions, cpn); } @@ -114,17 +117,12 @@ class Music { async search(query: string, filters: MusicSearchFilters = {}): Promise { throwIfMissing({ query }); - const payload: { - query: string; - client: string; - params?: string; - } = { query, client: 'YTMUSIC' }; - - if (filters.type && filters.type !== 'all') { - payload.params = Proto.encodeMusicSearchFilters(filters); - } - - const response = await this.#actions.execute('/search', payload); + const response = await this.#actions.execute( + SearchEndpoint.PATH, SearchEndpoint.build({ + query, client: 'YTMUSIC', + params: filters.type && filters.type !== 'all' ? Proto.encodeMusicSearchFilters(filters) : undefined + }) + ); return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all'); } @@ -133,10 +131,12 @@ class Music { * Retrieves the home feed. */ async getHomeFeed(): Promise { - const response = await this.#actions.execute('/browse', { - client: 'YTMUSIC', - browseId: 'FEmusic_home' - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'FEmusic_home', + client: 'YTMUSIC' + }) + ); return new HomeFeed(response, this.#actions); } @@ -145,10 +145,12 @@ class Music { * Retrieves the Explore feed. */ async getExplore(): Promise { - const response = await this.#actions.execute('/browse', { - client: 'YTMUSIC', - browseId: 'FEmusic_explore' - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC', + browse_id: 'FEmusic_explore' + }) + ); return new Explore(response); // TODO: return new Explore(response, this.#actions); @@ -158,10 +160,12 @@ class Music { * Retrieves the library. */ async getLibrary(): Promise { - const response = await this.#actions.execute('/browse', { - client: 'YTMUSIC', - browseId: 'FEmusic_library_landing' - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC', + browse_id: 'FEmusic_library_landing' + }) + ); return new Library(response, this.#actions); } @@ -176,10 +180,12 @@ class Music { 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.execute('/browse', { - client: 'YTMUSIC', - browseId: artist_id - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC', + browse_id: artist_id + }) + ); return new Artist(response, this.#actions); } @@ -194,10 +200,12 @@ class Music { if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release')) throw new InnertubeError('Invalid album id', album_id); - const response = await this.#actions.execute('/browse', { - client: 'YTMUSIC', - browseId: album_id - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC', + browse_id: album_id + }) + ); return new Album(response); } @@ -213,10 +221,12 @@ class Music { playlist_id = `VL${playlist_id}`; } - const response = await this.#actions.execute('/browse', { - client: 'YTMUSIC', - browseId: playlist_id - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC', + browse_id: playlist_id + }) + ); return new Playlist(response, this.#actions); } @@ -229,13 +239,11 @@ class Music { async getUpNext(video_id: string, automix = true): Promise { throwIfMissing({ video_id }); - const data = await this.#actions.execute('/next', { - videoId: video_id, - client: 'YTMUSIC', - parse: true - }); + const response = await this.#actions.execute( + NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true } + ); - const tabs = data.contents_memo?.getType(Tab); + const tabs = response.contents_memo?.getType(Tab); const tab = tabs?.first(); @@ -277,13 +285,11 @@ class Music { async getRelated(video_id: string): Promise> { throwIfMissing({ video_id }); - const data = await this.#actions.execute('/next', { - videoId: video_id, - client: 'YTMUSIC', - parse: true - }); + const response = await this.#actions.execute( + NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true } + ); - const tabs = data.contents_memo?.getType(Tab); + const tabs = response.contents_memo?.getType(Tab); const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED'); @@ -307,13 +313,11 @@ class Music { async getLyrics(video_id: string): Promise { throwIfMissing({ video_id }); - const data = await this.#actions.execute('/next', { - videoId: video_id, - client: 'YTMUSIC', - parse: true - }); + const response = await this.#actions.execute( + NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true } + ); - const tabs = data.contents_memo?.getType(Tab); + const tabs = response.contents_memo?.getType(Tab); const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS'); @@ -337,10 +341,12 @@ class Music { * Retrieves recap. */ async getRecap(): Promise { - const response = await this.#actions.execute('/browse', { - browseId: 'FEmusic_listening_review', - client: 'YTMUSIC_ANDROID' - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + client: 'YTMUSIC_ANDROID', + browse_id: 'FEmusic_listening_review' + }) + ); return new Recap(response, this.#actions); } @@ -350,11 +356,10 @@ class Music { * @param query - The query. */ async getSearchSuggestions(query: string): Promise> { - const response = await this.#actions.execute('/music/get_search_suggestions', { - parse: true, - input: query, - client: 'YTMUSIC' - }); + const response = await this.#actions.execute( + GetSearchSuggestionsEndpoint.PATH, + { ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true } + ); if (!response.contents_memo) throw new InnertubeError('Unexpected response', response); @@ -363,6 +368,4 @@ class Music { return search_suggestions_section.contents; } -} - -export default Music; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/Studio.ts b/src/core/clients/Studio.ts similarity index 73% rename from src/core/Studio.ts rename to src/core/clients/Studio.ts index fecce915..f59439c2 100644 --- a/src/core/Studio.ts +++ b/src/core/clients/Studio.ts @@ -1,9 +1,12 @@ -import Proto from '../proto/index.js'; -import { Constants } from '../utils/index.js'; -import { InnertubeError, MissingParamError, Platform } from '../utils/Utils.js'; +import Proto from '../../proto/index.js'; +import { Constants } from '../../utils/index.js'; +import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js'; -import type { ApiResponse } from './Actions.js'; -import type Session from './Session.js'; +import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Clients.js'; +import type { ApiResponse } from '../Actions.js'; +import type Session from '../Session.js'; + +import { CreateVideoEndpoint } from '../endpoints/upload/index.js'; interface UploadResult { status: string; @@ -18,25 +21,7 @@ interface InitialUploadData { chunk_granularity: string; } -export interface VideoMetadata { - title?: string; - description?: string; - tags?: string[]; - category?: number; - license?: string; - age_restricted?: boolean; - made_for_kids?: boolean; - privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; -} - -export interface UploadedVideoMetadata { - title?: string; - description?: string; - privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; - is_draft?: boolean; -} - -class Studio { +export default class Studio { #session: Session; constructor(session: Session) { @@ -69,7 +54,7 @@ class Studio { } /** - * Updates given video's metadata. + * Updates a given video's metadata. * @example * ```ts * const response = await yt.studio.updateVideoMetadata('videoid', { @@ -82,7 +67,7 @@ class Studio { * }); * ``` */ - async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise { + async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise { if (!this.#session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); @@ -104,7 +89,7 @@ class Studio { * const response = await yt.studio.upload(file.buffer, { title: 'Wow!' }); * ``` */ - async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise { + async upload(file: BodyInit, metadata: UploadedVideoMetadataOptions = {}): Promise { if (!this.#session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); @@ -174,38 +159,34 @@ class Studio { return data; } - async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) { - const metadata_payload = { - resourceId: { - scottyResourceId: { - id: upload_result.scottyResourceId - } - }, - frontendUploadId: initial_data.frontend_upload_id, - initialMetadata: { - title: { - newTitle: metadata.title || new Date().toDateString() + async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadataOptions) { + const response = await this.#session.actions.execute( + CreateVideoEndpoint.PATH, CreateVideoEndpoint.build({ + resource_id: { + scotty_resource_id: { + id: upload_result.scottyResourceId + } }, - description: { - newDescription: metadata.description || '', - shouldSegment: true + frontend_upload_id: initial_data.frontend_upload_id, + initial_metadata: { + title: { + new_title: metadata.title || new Date().toDateString() + }, + description: { + new_description: metadata.description || '', + should_segment: true + }, + privacy: { + new_privacy: metadata.privacy || 'PRIVATE' + }, + draft_state: { + is_draft: metadata.is_draft + } }, - privacy: { - newPrivacy: metadata.privacy || 'PRIVATE' - }, - draftState: { - isDraft: metadata.is_draft || false - } - } - }; - - const response = await this.#session.actions.execute('/upload/createvideo', { - client: 'ANDROID', - ...metadata_payload - }); + client: 'ANDROID' + }) + ); return response; } -} - -export default Studio; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts new file mode 100644 index 00000000..6b7fe705 --- /dev/null +++ b/src/core/clients/index.ts @@ -0,0 +1,3 @@ +export { default as Kids } from './Kids.js'; +export { default as Music } from './Music.js'; +export { default as Studio } from './Studio.js'; \ No newline at end of file diff --git a/src/core/endpoints/BrowseEndpoint.ts b/src/core/endpoints/BrowseEndpoint.ts new file mode 100644 index 00000000..be83efcc --- /dev/null +++ b/src/core/endpoints/BrowseEndpoint.ts @@ -0,0 +1,19 @@ +import type { IBrowseRequest, BrowseEndpointOptions } from '../../types/index.js'; + +export const PATH = '/browse'; + +/** + * Builds a `/browse` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: BrowseEndpointOptions): IBrowseRequest { + return { + ...{ + browseId: opts.browse_id, + params: opts.params, + continuation: opts.continuation, + client: opts.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/GetNotificationMenuEndpoint.ts b/src/core/endpoints/GetNotificationMenuEndpoint.ts new file mode 100644 index 00000000..e2c921bc --- /dev/null +++ b/src/core/endpoints/GetNotificationMenuEndpoint.ts @@ -0,0 +1,16 @@ +import type { IGetNotificationMenuRequest, GetNotificationMenuEndpointOptions } from '../../types/index.js'; + +export const PATH = '/notification/get_notification_menu'; + +/** + * Builds a `/get_notification_menu` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: GetNotificationMenuEndpointOptions): IGetNotificationMenuRequest { + return { + ...{ + notificationsMenuRequestType: opts.notifications_menu_request_type + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/GuideEndpoint.ts b/src/core/endpoints/GuideEndpoint.ts new file mode 100644 index 00000000..04198267 --- /dev/null +++ b/src/core/endpoints/GuideEndpoint.ts @@ -0,0 +1 @@ +export const PATH = '/guide'; \ No newline at end of file diff --git a/src/core/endpoints/NextEndpoint.ts b/src/core/endpoints/NextEndpoint.ts new file mode 100644 index 00000000..d3aec2bd --- /dev/null +++ b/src/core/endpoints/NextEndpoint.ts @@ -0,0 +1,21 @@ +import type { INextRequest, NextEndpointOptions } from '../../types/index.js'; + +export const PATH = '/next'; + +/** + * Builds a `/next` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: NextEndpointOptions): INextRequest { + return { + ...{ + videoId: opts.video_id, + playlistId: opts.playlist_id, + params: opts.params, + playlistIndex: opts.playlist_index, + client: opts.client, + continuation: opts.continuation + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/PlayerEndpoint.ts b/src/core/endpoints/PlayerEndpoint.ts new file mode 100644 index 00000000..b4331264 --- /dev/null +++ b/src/core/endpoints/PlayerEndpoint.ts @@ -0,0 +1,39 @@ +import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js'; + +export const PATH = '/player'; + +/** + * Builds a `/player` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: PlayerEndpointOptions): IPlayerRequest { + return { + playbackContext: { + contentPlaybackContext: { + vis: 0, + splay: false, + referer: opts.playlist_id ? + `https://www.youtube.com/watch?v=${opts.video_id}&list=${opts.playlist_id}` : + `https://www.youtube.com/watch?v=${opts.video_id}`, + currentUrl: opts.playlist_id ? + `/watch?v=${opts.video_id}&list=${opts.playlist_id}` : + `/watch?v=${opts.video_id}`, + autonavState: 'STATE_ON', + autoCaptionsDefaultOn: false, + html5Preference: 'HTML5_PREF_WANTS', + lactMilliseconds: '-1', + ...{ + signatureTimestamp: opts.sts + } + } + }, + racyCheckOk: true, + contentCheckOk: true, + videoId: opts.video_id, + ...{ + client: opts.client, + playlistId: opts.playlist_id + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/ResolveURLEndpoint.ts b/src/core/endpoints/ResolveURLEndpoint.ts new file mode 100644 index 00000000..de58674b --- /dev/null +++ b/src/core/endpoints/ResolveURLEndpoint.ts @@ -0,0 +1,16 @@ +import type { IResolveURLRequest, ResolveURLEndpointOptions } from '../../types/index.js'; + +export const PATH = '/navigation/resolve_url'; + +/** + * Builds a `/resolve_url` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: ResolveURLEndpointOptions): IResolveURLRequest { + return { + ...{ + url: opts.url + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/SearchEndpoint.ts b/src/core/endpoints/SearchEndpoint.ts new file mode 100644 index 00000000..3f2af35e --- /dev/null +++ b/src/core/endpoints/SearchEndpoint.ts @@ -0,0 +1,19 @@ +import type { ISearchRequest, SearchEndpointOptions } from '../../types/index.js'; + +export const PATH = '/search'; + +/** + * Builds a `/search` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: SearchEndpointOptions): ISearchRequest { + return { + ...{ + query: opts.query, + params: opts.params, + continuation: opts.continuation, + client: opts.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/account/AccountListEndpoint.ts b/src/core/endpoints/account/AccountListEndpoint.ts new file mode 100644 index 00000000..692c9fce --- /dev/null +++ b/src/core/endpoints/account/AccountListEndpoint.ts @@ -0,0 +1,13 @@ +import type { IAccountListRequest } from '../../../types/index.js'; + +export const PATH = '/account/accounts_list'; + +/** + * Builds a `/account/accounts_list` request payload. + * @returns The payload. + */ +export function build(): IAccountListRequest { + return { + client: 'ANDROID' + }; +} \ No newline at end of file diff --git a/src/core/endpoints/account/index.ts b/src/core/endpoints/account/index.ts new file mode 100644 index 00000000..afab49f1 --- /dev/null +++ b/src/core/endpoints/account/index.ts @@ -0,0 +1 @@ +export * as AccountListEndpoint from './AccountListEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/browse/EditPlaylistEndpoint.ts b/src/core/endpoints/browse/EditPlaylistEndpoint.ts new file mode 100644 index 00000000..83654c5b --- /dev/null +++ b/src/core/endpoints/browse/EditPlaylistEndpoint.ts @@ -0,0 +1,22 @@ +import type { IEditPlaylistRequest, EditPlaylistEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/browse/edit_playlist'; + +/** + * Builds a `/browse/edit_playlist` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: EditPlaylistEndpointOptions): IEditPlaylistRequest { + return { + playlistId: opts.playlist_id, + actions: opts.actions.map((action) => ({ + action: action.action, + ...{ + addedVideoId: action.added_video_id, + setVideoId: action.set_video_id, + movedSetVideoIdPredecessor: action.moved_set_video_id_predecessor + } + })) + }; +} \ No newline at end of file diff --git a/src/core/endpoints/browse/index.ts b/src/core/endpoints/browse/index.ts new file mode 100644 index 00000000..42e892e8 --- /dev/null +++ b/src/core/endpoints/browse/index.ts @@ -0,0 +1 @@ +export * as EditPlaylistEndpoint from './EditPlaylistEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/channel/EditDescriptionEndpoint.ts b/src/core/endpoints/channel/EditDescriptionEndpoint.ts new file mode 100644 index 00000000..cb2967d4 --- /dev/null +++ b/src/core/endpoints/channel/EditDescriptionEndpoint.ts @@ -0,0 +1,15 @@ +import type { IChannelEditDescriptionRequest, ChannelEditDescriptionEndpointOptions } from '../../../types/Endpoints.js'; + +export const PATH = '/channel/edit_description'; + +/** + * Builds a `/channel/edit_description` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: ChannelEditDescriptionEndpointOptions): IChannelEditDescriptionRequest { + return { + givenDescription: options.given_description, + client: 'ANDROID' + }; +} \ No newline at end of file diff --git a/src/core/endpoints/channel/EditNameEndpoint.ts b/src/core/endpoints/channel/EditNameEndpoint.ts new file mode 100644 index 00000000..35e95290 --- /dev/null +++ b/src/core/endpoints/channel/EditNameEndpoint.ts @@ -0,0 +1,15 @@ +import type { IChannelEditNameRequest, ChannelEditNameEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/channel/edit_name'; + +/** + * Builds a `/channel/edit_name` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: ChannelEditNameEndpointOptions): IChannelEditNameRequest { + return { + givenName: options.given_name, + client: 'ANDROID' + }; +} \ No newline at end of file diff --git a/src/core/endpoints/channel/index.ts b/src/core/endpoints/channel/index.ts new file mode 100644 index 00000000..5169c941 --- /dev/null +++ b/src/core/endpoints/channel/index.ts @@ -0,0 +1,2 @@ +export * as EditNameEndpoint from './EditNameEndpoint.js'; +export * as EditDescriptionEndpoint from './EditDescriptionEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/comment/CreateCommentEndpoint.ts b/src/core/endpoints/comment/CreateCommentEndpoint.ts new file mode 100644 index 00000000..b925659f --- /dev/null +++ b/src/core/endpoints/comment/CreateCommentEndpoint.ts @@ -0,0 +1,18 @@ +import type { ICreateCommentRequest, CreateCommentEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/comment/create_comment'; + +/** + * Builds a `/comment/create_comment` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: CreateCommentEndpointOptions): ICreateCommentRequest { + return { + commentText: options.comment_text, + createCommentParams: options.create_comment_params, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/comment/PerformCommentActionEndpoint.ts b/src/core/endpoints/comment/PerformCommentActionEndpoint.ts new file mode 100644 index 00000000..ba982b41 --- /dev/null +++ b/src/core/endpoints/comment/PerformCommentActionEndpoint.ts @@ -0,0 +1,17 @@ +import type { IPerformCommentActionRequest, PerformCommentActionEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/comment/perform_comment_action'; + +/** + * Builds a `/comment/perform_comment_action` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: PerformCommentActionEndpointOptions): IPerformCommentActionRequest { + return { + actions: options.actions, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/comment/index.ts b/src/core/endpoints/comment/index.ts new file mode 100644 index 00000000..859be212 --- /dev/null +++ b/src/core/endpoints/comment/index.ts @@ -0,0 +1,2 @@ +export * as PerformCommentActionEndpoint from './PerformCommentActionEndpoint.js'; +export * as CreateCommentEndpoint from './CreateCommentEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/index.ts b/src/core/endpoints/index.ts new file mode 100644 index 00000000..8837334c --- /dev/null +++ b/src/core/endpoints/index.ts @@ -0,0 +1,18 @@ +export * as BrowseEndpoint from './BrowseEndpoint.js'; +export * as GetNotificationMenuEndpoint from './GetNotificationMenuEndpoint.js'; +export * as GuideEndpoint from './GuideEndpoint.js'; +export * as NextEndpoint from './NextEndpoint.js'; +export * as PlayerEndpoint from './PlayerEndpoint.js'; +export * as ResolveURLEndpoint from './ResolveURLEndpoint.js'; +export * as SearchEndpoint from './SearchEndpoint.js'; + +export * as Account from './account/index.js'; +export * as Browse from './browse/index.js'; +export * as Channel from './channel/index.js'; +export * as Comment from './comment/index.js'; +export * as Like from './like/index.js'; +export * as Music from './music/index.js'; +export * as Notification from './notification/index.js'; +export * as Playlist from './playlist/index.js'; +export * as Subscription from './subscription/index.js'; +export * as Upload from './upload/index.js'; \ No newline at end of file diff --git a/src/core/endpoints/like/DislikeEndpoint.ts b/src/core/endpoints/like/DislikeEndpoint.ts new file mode 100644 index 00000000..ef92234e --- /dev/null +++ b/src/core/endpoints/like/DislikeEndpoint.ts @@ -0,0 +1,19 @@ +import { IDislikeRequest, DislikeEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/like/dislike'; + +/** + * Builds a `/like/dislike` endpoint payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: DislikeEndpointOptions): IDislikeRequest { + return { + target: { + videoId: options.target.video_id + }, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/like/LikeEndpoint.ts b/src/core/endpoints/like/LikeEndpoint.ts new file mode 100644 index 00000000..0f8c879a --- /dev/null +++ b/src/core/endpoints/like/LikeEndpoint.ts @@ -0,0 +1,19 @@ +import type { ILikeRequest, LikeEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/like/like'; + +/** + * Builds a `/like/like` endpoint payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: LikeEndpointOptions): ILikeRequest { + return { + target: { + videoId: options.target.video_id + }, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/like/RemoveLikeEndpoint.ts b/src/core/endpoints/like/RemoveLikeEndpoint.ts new file mode 100644 index 00000000..7852af07 --- /dev/null +++ b/src/core/endpoints/like/RemoveLikeEndpoint.ts @@ -0,0 +1,19 @@ +import { IRemoveLikeRequest, RemoveLikeEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/like/removelike'; + +/** + * Builds a `/like/removelike` endpoint payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: RemoveLikeEndpointOptions): IRemoveLikeRequest { + return { + target: { + videoId: options.target.video_id + }, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/like/index.ts b/src/core/endpoints/like/index.ts new file mode 100644 index 00000000..8dbda184 --- /dev/null +++ b/src/core/endpoints/like/index.ts @@ -0,0 +1,3 @@ +export * as LikeEndpoint from './LikeEndpoint.js'; +export * as DislikeEndpoint from './DislikeEndpoint.js'; +export * as RemoveLikeEndpoint from './RemoveLikeEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/music/GetSearchSuggestionsEndpoint.ts b/src/core/endpoints/music/GetSearchSuggestionsEndpoint.ts new file mode 100644 index 00000000..dd67a986 --- /dev/null +++ b/src/core/endpoints/music/GetSearchSuggestionsEndpoint.ts @@ -0,0 +1,16 @@ +import type { IMusicGetSearchSuggestionsRequest, MusicGetSearchSuggestionsEndpointOptions } from '../../../types/index.js'; + + +export const PATH = '/music/get_search_suggestions'; + +/** + * Builds a `/music/get_search_suggestions` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: MusicGetSearchSuggestionsEndpointOptions): IMusicGetSearchSuggestionsRequest { + return { + input: opts.input, + client: 'YTMUSIC' + }; +} \ No newline at end of file diff --git a/src/core/endpoints/music/index.ts b/src/core/endpoints/music/index.ts new file mode 100644 index 00000000..fd9f39bc --- /dev/null +++ b/src/core/endpoints/music/index.ts @@ -0,0 +1 @@ +export * as GetSearchSuggestionsEndpoint from './GetSearchSuggestionsEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/notification/GetUnseenCountEndpoint.ts b/src/core/endpoints/notification/GetUnseenCountEndpoint.ts new file mode 100644 index 00000000..3d3bd902 --- /dev/null +++ b/src/core/endpoints/notification/GetUnseenCountEndpoint.ts @@ -0,0 +1 @@ +export const PATH = '/notification/get_unseen_count'; \ No newline at end of file diff --git a/src/core/endpoints/notification/ModifyChannelPreferenceEndpoint.ts b/src/core/endpoints/notification/ModifyChannelPreferenceEndpoint.ts new file mode 100644 index 00000000..ca9c6c4b --- /dev/null +++ b/src/core/endpoints/notification/ModifyChannelPreferenceEndpoint.ts @@ -0,0 +1,17 @@ +import type { IModifyChannelPreferenceRequest, ModifyChannelPreferenceEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/notification/modify_channel_preference'; + +/** + * Builds a `/notification/modify_channel_preference` request payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: ModifyChannelPreferenceEndpointOptions): IModifyChannelPreferenceRequest { + return { + params: options.params, + ...{ + client: options.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/notification/index.ts b/src/core/endpoints/notification/index.ts new file mode 100644 index 00000000..7f66c9c1 --- /dev/null +++ b/src/core/endpoints/notification/index.ts @@ -0,0 +1,2 @@ +export * as GetUnseenCountEndpoint from './GetUnseenCountEndpoint.js'; +export * as ModifyChannelPreferenceEndpoint from './ModifyChannelPreferenceEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/playlist/CreateEndpoint.ts b/src/core/endpoints/playlist/CreateEndpoint.ts new file mode 100644 index 00000000..886e16ad --- /dev/null +++ b/src/core/endpoints/playlist/CreateEndpoint.ts @@ -0,0 +1,15 @@ +import type { ICreatePlaylistRequest, CreatePlaylistEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/playlist/create'; + +/** + * Builds a `/playlist/create` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: CreatePlaylistEndpointOptions): ICreatePlaylistRequest { + return { + title: opts.title, + ids: opts.ids + }; +} \ No newline at end of file diff --git a/src/core/endpoints/playlist/DeleteEndpoint.ts b/src/core/endpoints/playlist/DeleteEndpoint.ts new file mode 100644 index 00000000..134f5a15 --- /dev/null +++ b/src/core/endpoints/playlist/DeleteEndpoint.ts @@ -0,0 +1,14 @@ +import type { IDeletePlaylistRequest, DeletePlaylistEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/playlist/delete'; + +/** + * Builds a `/playlist/delete` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: DeletePlaylistEndpointOptions): IDeletePlaylistRequest { + return { + playlistId: opts.playlist_id + }; +} \ No newline at end of file diff --git a/src/core/endpoints/playlist/index.ts b/src/core/endpoints/playlist/index.ts new file mode 100644 index 00000000..0869530a --- /dev/null +++ b/src/core/endpoints/playlist/index.ts @@ -0,0 +1,2 @@ +export * as CreateEndpoint from './CreateEndpoint.js'; +export * as DeleteEndpoint from './DeleteEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/subscription/SubscribeEndpoint.ts b/src/core/endpoints/subscription/SubscribeEndpoint.ts new file mode 100644 index 00000000..5e5e6926 --- /dev/null +++ b/src/core/endpoints/subscription/SubscribeEndpoint.ts @@ -0,0 +1,18 @@ +import type { ISubscribeRequest, SubscribeEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/subscription/subscribe'; + +/** + * Builds a `/subscription/subscribe` endpoint payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: SubscribeEndpointOptions): ISubscribeRequest { + return { + channelIds: options.channel_ids, + ...{ + client: options.client, + params: options.params + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/subscription/UnsubscribeEndpoint.ts b/src/core/endpoints/subscription/UnsubscribeEndpoint.ts new file mode 100644 index 00000000..4e2c1c62 --- /dev/null +++ b/src/core/endpoints/subscription/UnsubscribeEndpoint.ts @@ -0,0 +1,18 @@ +import type { IUnsubscribeRequest, UnsubscribeEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/subscription/unsubscribe'; + +/** + * Builds a `/subscription/unsubscribe` endpoint payload. + * @param options - The options to use. + * @returns The payload. + */ +export function build(options: UnsubscribeEndpointOptions): IUnsubscribeRequest { + return { + channelIds: options.channel_ids, + ...{ + client: options.client, + params: options.params + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/subscription/index.ts b/src/core/endpoints/subscription/index.ts new file mode 100644 index 00000000..927a8bd6 --- /dev/null +++ b/src/core/endpoints/subscription/index.ts @@ -0,0 +1,2 @@ +export * as SubscribeEndpoint from './SubscribeEndpoint.js'; +export * as UnsubscribeEndpoint from './UnsubscribeEndpoint.js'; \ No newline at end of file diff --git a/src/core/endpoints/upload/CreateVideoEndpoint.ts b/src/core/endpoints/upload/CreateVideoEndpoint.ts new file mode 100644 index 00000000..8a919763 --- /dev/null +++ b/src/core/endpoints/upload/CreateVideoEndpoint.ts @@ -0,0 +1,37 @@ +import type { ICreateVideoRequest, CreateVideoEndpointOptions } from '../../../types/index.js'; + +export const PATH = '/upload/createvideo'; + +/** + * Builds a `/upload/createvideo` request payload. + * @param opts - The options to use. + * @returns The payload. + */ +export function build(opts: CreateVideoEndpointOptions): ICreateVideoRequest { + return { + resourceId: { + scottyResourceId: { + id: opts.resource_id.scotty_resource_id.id + } + }, + frontendUploadId: opts.frontend_upload_id, + initialMetadata: { + title: { + newTitle: opts.initial_metadata.title.new_title + }, + description: { + newDescription: opts.initial_metadata.description.new_description, + shouldSegment: opts.initial_metadata.description.should_segment + }, + privacy: { + newPrivacy: opts.initial_metadata.privacy.new_privacy + }, + draftState: { + isDraft: !!opts.initial_metadata.draft_state.is_draft + } + }, + ...{ + client: opts.client + } + }; +} \ No newline at end of file diff --git a/src/core/endpoints/upload/index.ts b/src/core/endpoints/upload/index.ts new file mode 100644 index 00000000..5798d2bb --- /dev/null +++ b/src/core/endpoints/upload/index.ts @@ -0,0 +1 @@ +export * as CreateVideoEndpoint from './CreateVideoEndpoint.js'; \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index a38ea082..c0455249 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,38 +1,16 @@ -export { default as AccountManager } from './AccountManager.js'; -export * from './AccountManager.js'; +export { default as Session } from './Session.js'; +export * from './Session.js'; export { default as Actions } from './Actions.js'; export * from './Actions.js'; -export { default as Feed } from './Feed.js'; -export * from './Feed.js'; - -export { default as FilterableFeed } from './FilterableFeed.js'; -export * from './FilterableFeed.js'; - -export { default as InteractionManager } from './InteractionManager.js'; -export * from './InteractionManager.js'; - -export { default as Kids } from './Kids.js'; -export * from './Kids.js'; - -export { default as Music } from './Music.js'; -export * from './Music.js'; +export { default as Player } from './Player.js'; +export * from './Player.js'; export { default as OAuth } from './OAuth.js'; export * from './OAuth.js'; -export { default as Player } from './Player.js'; -export * from './Player.js'; - -export { default as PlaylistManager } from './PlaylistManager.js'; -export * from './PlaylistManager.js'; - -export { default as Session } from './Session.js'; -export * from './Session.js'; - -export { default as Studio } from './Studio.js'; -export * from './Studio.js'; - -export { default as TabbedFeed } from './TabbedFeed.js'; -export * from './TabbedFeed.js'; +export * as Clients from './clients/index.js'; +export * as Endpoints from './endpoints/index.js'; +export * as Managers from './managers/index.js'; +export * as Mixins from './mixins/index.js'; \ No newline at end of file diff --git a/src/core/AccountManager.ts b/src/core/managers/AccountManager.ts similarity index 50% rename from src/core/AccountManager.ts rename to src/core/managers/AccountManager.ts index 0e35c0dd..b284c397 100644 --- a/src/core/AccountManager.ts +++ b/src/core/managers/AccountManager.ts @@ -1,15 +1,16 @@ -import Proto from '../proto/index.js'; -import type Actions from './Actions.js'; -import type { ApiResponse } from './Actions.js'; +import AccountInfo from '../../parser/youtube/AccountInfo.js'; +import Analytics from '../../parser/youtube/Analytics.js'; +import Settings from '../../parser/youtube/Settings.js'; +import TimeWatched from '../../parser/youtube/TimeWatched.js'; -import Analytics from '../parser/youtube/Analytics.js'; -import TimeWatched from '../parser/youtube/TimeWatched.js'; -import AccountInfo from '../parser/youtube/AccountInfo.js'; -import Settings from '../parser/youtube/Settings.js'; +import Proto from '../../proto/index.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import { Account, BrowseEndpoint, Channel } from '../endpoints/index.js'; -import { InnertubeError } from '../utils/Utils.js'; +import type Actions from '../Actions.js'; +import type { ApiResponse } from '../Actions.js'; -class AccountManager { +export default class AccountManager { #actions: Actions; channel: { @@ -30,10 +31,12 @@ class AccountManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - return this.#actions.execute('/channel/edit_name', { - givenName: new_name, - client: 'ANDROID' - }); + return this.#actions.execute( + Channel.EditNameEndpoint.PATH, + Channel.EditNameEndpoint.build({ + given_name: new_name + }) + ); }, /** * Edits channel description. @@ -43,10 +46,12 @@ class AccountManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - return this.#actions.execute('/channel/edit_description', { - givenDescription: new_description, - client: 'ANDROID' - }); + return this.#actions.execute( + Channel.EditDescriptionEndpoint.PATH, + Channel.EditDescriptionEndpoint.build({ + given_description: new_description + }) + ); }, /** * Retrieves basic channel analytics. @@ -62,7 +67,11 @@ class AccountManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' }); + const response = await this.#actions.execute( + Account.AccountListEndpoint.PATH, + Account.AccountListEndpoint.build() + ); + return new AccountInfo(response); } @@ -70,10 +79,12 @@ class AccountManager { * Retrieves time watched statistics. */ async getTimeWatched(): Promise { - const response = await this.#actions.execute('/browse', { - browseId: 'SPtime_watched', - client: 'ANDROID' - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'SPtime_watched', + client: 'ANDROID' + }) + ); return new TimeWatched(response); } @@ -82,10 +93,11 @@ class AccountManager { * Opens YouTube settings. */ async getSettings(): Promise { - const response = await this.#actions.execute('/browse', { - browseId: 'SPaccount_overview' - }); - + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'SPaccount_overview' + }) + ); return new Settings(this.#actions, response); } @@ -95,16 +107,14 @@ class AccountManager { async getAnalytics(): Promise { const info = await this.getInfo(); - const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId); - - const response = await this.#actions.execute('/browse', { - browseId: 'FEanalytics_screen', - client: 'ANDROID', - params - }); + const response = await this.#actions.execute( + BrowseEndpoint.PATH, BrowseEndpoint.build({ + browse_id: 'FEanalytics_screen', + params: Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId), + client: 'ANDROID' + }) + ); return new Analytics(response); } -} - -export default AccountManager; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/InteractionManager.ts b/src/core/managers/InteractionManager.ts similarity index 59% rename from src/core/InteractionManager.ts rename to src/core/managers/InteractionManager.ts index 1798fefa..151bf044 100644 --- a/src/core/InteractionManager.ts +++ b/src/core/managers/InteractionManager.ts @@ -1,9 +1,14 @@ -import Proto from '../proto/index.js'; -import type Actions from './Actions.js'; -import type { ApiResponse } from './Actions.js'; -import { throwIfMissing } from '../utils/Utils.js'; +import Proto from '../../proto/index.js'; +import type Actions from '../Actions.js'; +import type { ApiResponse } from '../Actions.js'; -class InteractionManager { +import { throwIfMissing } from '../../utils/Utils.js'; +import { LikeEndpoint, DislikeEndpoint, RemoveLikeEndpoint } from '../endpoints/like/index.js'; +import { SubscribeEndpoint, UnsubscribeEndpoint } from '../endpoints/subscription/index.js'; +import { CreateCommentEndpoint, PerformCommentActionEndpoint } from '../endpoints/comment/index.js'; +import { ModifyChannelPreferenceEndpoint } from '../endpoints/notification/index.js'; + +export default class InteractionManager { #actions: Actions; constructor(actions: Actions) { @@ -20,12 +25,12 @@ class InteractionManager { if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/like/like', { - client: 'ANDROID', - target: { - videoId: video_id - } - }); + const action = await this.#actions.execute( + LikeEndpoint.PATH, LikeEndpoint.build({ + client: 'ANDROID', + target: { video_id } + }) + ); return action; } @@ -40,12 +45,12 @@ class InteractionManager { if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/like/dislike', { - client: 'ANDROID', - target: { - videoId: video_id - } - }); + const action = await this.#actions.execute( + DislikeEndpoint.PATH, DislikeEndpoint.build({ + client: 'ANDROID', + target: { video_id } + }) + ); return action; } @@ -60,12 +65,12 @@ class InteractionManager { if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/like/removelike', { - client: 'ANDROID', - target: { - videoId: video_id - } - }); + const action = await this.#actions.execute( + RemoveLikeEndpoint.PATH, RemoveLikeEndpoint.build({ + client: 'ANDROID', + target: { video_id } + }) + ); return action; } @@ -80,11 +85,13 @@ class InteractionManager { if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/subscription/subscribe', { - client: 'ANDROID', - channelIds: [ channel_id ], - params: 'EgIIAhgA' - }); + const action = await this.#actions.execute( + SubscribeEndpoint.PATH, SubscribeEndpoint.build({ + client: 'ANDROID', + channel_ids: [ channel_id ], + params: 'EgIIAhgA' + }) + ); return action; } @@ -93,17 +100,19 @@ class InteractionManager { * Unsubscribes from a given channel. * @param channel_id - The channel ID */ - async unsubscribe(channel_id: string): Promise{ + async unsubscribe(channel_id: string): Promise { throwIfMissing({ channel_id }); if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/subscription/unsubscribe', { - client: 'ANDROID', - channelIds: [ channel_id ], - params: 'CgIIAhgA' - }); + const action = await this.#actions.execute( + UnsubscribeEndpoint.PATH, UnsubscribeEndpoint.build({ + client: 'ANDROID', + channel_ids: [ channel_id ], + params: 'CgIIAhgA' + }) + ); return action; } @@ -119,11 +128,13 @@ class InteractionManager { if (!this.#actions.session.logged_in) throw new Error('You must be signed in to perform this operation.'); - const action = await this.#actions.execute('/comment/create_comment', { - client: 'ANDROID', - commentText: text, - createCommentParams: Proto.encodeCommentParams(video_id) - }); + const action = await this.#actions.execute( + CreateCommentEndpoint.PATH, CreateCommentEndpoint.build({ + comment_text: text, + create_comment_params: Proto.encodeCommentParams(video_id), + client: 'ANDROID' + }) + ); return action; } @@ -139,10 +150,12 @@ class InteractionManager { const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args }); - const response = await this.#actions.execute('/comment/perform_comment_action', { - client: 'ANDROID', - actions: [ target_action ] - }); + const response = await this.#actions.execute( + PerformCommentActionEndpoint.PATH, PerformCommentActionEndpoint.build({ + client: 'ANDROID', + actions: [ target_action ] + }) + ); const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload; @@ -175,13 +188,13 @@ class InteractionManager { if (!Object.keys(pref_types).includes(type.toUpperCase())) throw new Error(`Invalid notification preference type: ${type}`); - const action = await this.#actions.execute('/notification/modify_channel_preference', { - client: 'WEB', - params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types]) - }); + const action = await this.#actions.execute( + ModifyChannelPreferenceEndpoint.PATH, ModifyChannelPreferenceEndpoint.build({ + client: 'WEB', + params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types]) + }) + ); return action; } -} - -export default InteractionManager; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/PlaylistManager.ts b/src/core/managers/PlaylistManager.ts similarity index 71% rename from src/core/PlaylistManager.ts rename to src/core/managers/PlaylistManager.ts index 3d81ce55..59727357 100644 --- a/src/core/PlaylistManager.ts +++ b/src/core/managers/PlaylistManager.ts @@ -1,10 +1,14 @@ -import type Feed from './Feed.js'; -import type Actions from './Actions.js'; -import Playlist from '../parser/youtube/Playlist.js'; +import Playlist from '../../parser/youtube/Playlist.js'; +import type Actions from '../Actions.js'; +import type Feed from '../mixins/Feed.js'; -import { InnertubeError, throwIfMissing } from '../utils/Utils.js'; +import type { EditPlaylistEndpointOptions } from '../../types/index.js'; +import { InnertubeError, throwIfMissing } from '../../utils/Utils.js'; +import { EditPlaylistEndpoint } from '../endpoints/browse/index.js'; +import { BrowseEndpoint } from '../endpoints/index.js'; +import { CreateEndpoint, DeleteEndpoint } from '../endpoints/playlist/index.js'; -class PlaylistManager { +export default class PlaylistManager { #actions: Actions; constructor(actions: Actions) { @@ -22,11 +26,12 @@ class PlaylistManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const response = await this.#actions.execute('/playlist/create', { - title, - ids: video_ids, - parse: false - }); + const response = await this.#actions.execute( + CreateEndpoint.PATH, CreateEndpoint.build({ + ids: video_ids, + title + }) + ); return { success: response.success, @@ -46,7 +51,11 @@ class PlaylistManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id }); + const response = await this.#actions.execute( + DeleteEndpoint.PATH, DeleteEndpoint.build({ + playlist_id + }) + ); return { playlist_id, @@ -67,14 +76,15 @@ class PlaylistManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const response = await this.#actions.execute('/browse/edit_playlist', { - playlistId: playlist_id, - actions: video_ids.map((id) => ({ - action: 'ACTION_ADD_VIDEO', - addedVideoId: id - })), - parse: false - }); + const response = await this.#actions.execute( + EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build({ + actions: video_ids.map((id) => ({ + action: 'ACTION_ADD_VIDEO', + added_video_id: id + })), + playlist_id + }) + ); return { playlist_id, @@ -93,23 +103,16 @@ class PlaylistManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const info = await this.#actions.execute('/browse', { - browseId: `VL${playlist_id}`, - parse: true - }); + const info = await this.#actions.execute( + BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true } + ); const playlist = new Playlist(this.#actions, info, true); if (!playlist.info.is_editable) throw new InnertubeError('This playlist cannot be edited.', playlist_id); - const payload = { - playlistId: playlist_id, - actions: [] as { - action: string; - setVideoId: string; - }[] - }; + const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] }; const getSetVideoIds = async (pl: Feed): Promise => { const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string())); @@ -117,7 +120,7 @@ class PlaylistManager { videos.forEach((video) => payload.actions.push({ action: 'ACTION_REMOVE_VIDEO', - setVideoId: video.key('set_video_id').string() + set_video_id: video.key('set_video_id').string() }) ); @@ -132,7 +135,9 @@ class PlaylistManager { if (!payload.actions.length) throw new InnertubeError('Given video ids were not found in this playlist.', video_ids); - const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false }); + const response = await this.#actions.execute( + EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload) + ); return { playlist_id, @@ -152,24 +157,16 @@ class PlaylistManager { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); - const info = await this.#actions.execute('/browse', { - browseId: `VL${playlist_id}`, - parse: true - }); + const info = await this.#actions.execute( + BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true } + ); const playlist = new Playlist(this.#actions, info, true); if (!playlist.info.is_editable) throw new InnertubeError('This playlist cannot be edited.', playlist_id); - const payload = { - playlistId: playlist_id, - actions: [] as { - action: string, - setVideoId?: string, - movedSetVideoIdPredecessor?: string - }[] - }; + const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] }; let set_video_id_0: string | undefined, set_video_id_1: string | undefined; @@ -190,20 +187,17 @@ class PlaylistManager { payload.actions.push({ action: 'ACTION_MOVE_VIDEO_AFTER', - setVideoId: set_video_id_0, - movedSetVideoIdPredecessor: set_video_id_1 + set_video_id: set_video_id_0, + moved_set_video_id_predecessor: set_video_id_1 }); - const response = await this.#actions.execute('/browse/edit_playlist', { - ...payload, - parse: false - }); + const response = await this.#actions.execute( + EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload) + ); return { playlist_id, action_result: response.data.actions // TODO: implement actions in the parser }; } -} - -export default PlaylistManager; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/managers/index.ts b/src/core/managers/index.ts new file mode 100644 index 00000000..1fcb25d1 --- /dev/null +++ b/src/core/managers/index.ts @@ -0,0 +1,3 @@ +export { default as AccountManager } from './AccountManager.js'; +export { default as PlaylistManager } from './PlaylistManager.js'; +export { default as InteractionManager } from './InteractionManager.js'; \ No newline at end of file diff --git a/src/core/Feed.ts b/src/core/mixins/Feed.ts similarity index 68% rename from src/core/Feed.ts rename to src/core/mixins/Feed.ts index 382b8f56..ec4d1b9e 100644 --- a/src/core/Feed.ts +++ b/src/core/mixins/Feed.ts @@ -1,40 +1,40 @@ -import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.js'; -import Parser, { ReloadContinuationItemsCommand } from '../parser/index.js'; -import { concatMemos, InnertubeError } from '../utils/Utils.js'; -import type Actions from './Actions.js'; +import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../../parser/helpers.js'; +import Parser, { ReloadContinuationItemsCommand } from '../../parser/index.js'; +import { concatMemos, InnertubeError } from '../../utils/Utils.js'; +import type Actions from '../Actions.js'; -import BackstagePost from '../parser/classes/BackstagePost.js'; -import SharedPost from '../parser/classes/SharedPost.js'; -import Channel from '../parser/classes/Channel.js'; -import CompactVideo from '../parser/classes/CompactVideo.js'; -import GridChannel from '../parser/classes/GridChannel.js'; -import GridPlaylist from '../parser/classes/GridPlaylist.js'; -import GridVideo from '../parser/classes/GridVideo.js'; -import Playlist from '../parser/classes/Playlist.js'; -import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo.js'; -import PlaylistVideo from '../parser/classes/PlaylistVideo.js'; -import Post from '../parser/classes/Post.js'; -import ReelItem from '../parser/classes/ReelItem.js'; -import ReelShelf from '../parser/classes/ReelShelf.js'; -import RichShelf from '../parser/classes/RichShelf.js'; -import Shelf from '../parser/classes/Shelf.js'; -import Tab from '../parser/classes/Tab.js'; -import Video from '../parser/classes/Video.js'; +import BackstagePost from '../../parser/classes/BackstagePost.js'; +import SharedPost from '../../parser/classes/SharedPost.js'; +import Channel from '../../parser/classes/Channel.js'; +import CompactVideo from '../../parser/classes/CompactVideo.js'; +import GridChannel from '../../parser/classes/GridChannel.js'; +import GridPlaylist from '../../parser/classes/GridPlaylist.js'; +import GridVideo from '../../parser/classes/GridVideo.js'; +import Playlist from '../../parser/classes/Playlist.js'; +import PlaylistPanelVideo from '../../parser/classes/PlaylistPanelVideo.js'; +import PlaylistVideo from '../../parser/classes/PlaylistVideo.js'; +import Post from '../../parser/classes/Post.js'; +import ReelItem from '../../parser/classes/ReelItem.js'; +import ReelShelf from '../../parser/classes/ReelShelf.js'; +import RichShelf from '../../parser/classes/RichShelf.js'; +import Shelf from '../../parser/classes/Shelf.js'; +import Tab from '../../parser/classes/Tab.js'; +import Video from '../../parser/classes/Video.js'; -import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction.js'; -import ContinuationItem from '../parser/classes/ContinuationItem.js'; -import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults.js'; -import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults.js'; -import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo.js'; +import AppendContinuationItemsAction from '../../parser/classes/actions/AppendContinuationItemsAction.js'; +import ContinuationItem from '../../parser/classes/ContinuationItem.js'; +import TwoColumnBrowseResults from '../../parser/classes/TwoColumnBrowseResults.js'; +import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.js'; +import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.js'; -import type MusicQueue from '../parser/classes/MusicQueue.js'; -import type RichGrid from '../parser/classes/RichGrid.js'; -import type SectionList from '../parser/classes/SectionList.js'; +import type MusicQueue from '../../parser/classes/MusicQueue.js'; +import type RichGrid from '../../parser/classes/RichGrid.js'; +import type SectionList from '../../parser/classes/SectionList.js'; -import type { IParsedResponse } from '../parser/types/index.js'; -import type { ApiResponse } from './Actions.js'; +import type { IParsedResponse } from '../../parser/types/index.js'; +import type { ApiResponse } from '../Actions.js'; -class Feed { +export default class Feed { #page: T; #continuation?: ObservedArray; #actions: Actions; @@ -210,6 +210,4 @@ class Feed { throw new InnertubeError('Could not get continuation data'); return new Feed(this.actions, continuation_data, true); } -} - -export default Feed; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/FilterableFeed.ts b/src/core/mixins/FilterableFeed.ts similarity index 78% rename from src/core/FilterableFeed.ts rename to src/core/mixins/FilterableFeed.ts index 9850bd38..0f57d6a4 100644 --- a/src/core/FilterableFeed.ts +++ b/src/core/mixins/FilterableFeed.ts @@ -1,14 +1,14 @@ -import ChipCloudChip from '../parser/classes/ChipCloudChip.js'; -import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar.js'; -import { InnertubeError } from '../utils/Utils.js'; +import ChipCloudChip from '../../parser/classes/ChipCloudChip.js'; +import FeedFilterChipBar from '../../parser/classes/FeedFilterChipBar.js'; +import { InnertubeError } from '../../utils/Utils.js'; import Feed from './Feed.js'; -import type { ObservedArray } from '../parser/helpers.js'; -import type { IParsedResponse } from '../parser/types/ParsedResponse.js'; -import type Actions from './Actions.js'; -import type { ApiResponse } from './Actions.js'; +import type { ObservedArray } from '../../parser/helpers.js'; +import type { IParsedResponse } from '../../parser/types/ParsedResponse.js'; +import type Actions from '../Actions.js'; +import type { ApiResponse } from '../Actions.js'; -class FilterableFeed extends Feed { +export default class FilterableFeed extends Feed { #chips?: ObservedArray; constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) { @@ -69,6 +69,4 @@ class FilterableFeed extends Feed { return new Feed(this.actions, response, true); } -} - -export default FilterableFeed; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/MediaInfo.ts b/src/core/mixins/MediaInfo.ts similarity index 87% rename from src/core/MediaInfo.ts rename to src/core/mixins/MediaInfo.ts index a4e8e106..9160112b 100644 --- a/src/core/MediaInfo.ts +++ b/src/core/mixins/MediaInfo.ts @@ -1,11 +1,11 @@ -import Actions, { ApiResponse } from './Actions.js'; -import Constants from '../utils/Constants.js'; -import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../utils/FormatUtils.js'; -import { InnertubeError } from '../utils/Utils.js'; -import Format from '../parser/classes/misc/Format.js'; -import Parser, { INextResponse, IPlayerResponse } from '../parser/index.js'; +import Actions, { ApiResponse } from '../Actions.js'; +import Constants from '../../utils/Constants.js'; +import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../utils/FormatUtils.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import Format from '../../parser/classes/misc/Format.js'; +import Parser, { INextResponse, IPlayerResponse } from '../../parser/index.js'; -export class MediaInfo { +export default class MediaInfo { #page: [IPlayerResponse, INextResponse?]; #actions: Actions; #cpn: string; diff --git a/src/core/TabbedFeed.ts b/src/core/mixins/TabbedFeed.ts similarity index 78% rename from src/core/TabbedFeed.ts rename to src/core/mixins/TabbedFeed.ts index 4fe129c5..9cc67e2a 100644 --- a/src/core/TabbedFeed.ts +++ b/src/core/mixins/TabbedFeed.ts @@ -1,13 +1,13 @@ -import Tab from '../parser/classes/Tab.js'; +import Tab from '../../parser/classes/Tab.js'; import Feed from './Feed.js'; -import { InnertubeError } from '../utils/Utils.js'; +import { InnertubeError } from '../../utils/Utils.js'; -import type Actions from './Actions.js'; -import type { ObservedArray } from '../parser/helpers.js'; -import type { IParsedResponse } from '../parser/types/ParsedResponse.js'; -import type { ApiResponse } from './Actions.js'; +import type Actions from '../Actions.js'; +import type { ObservedArray } from '../../parser/helpers.js'; +import type { IParsedResponse } from '../../parser/types/ParsedResponse.js'; +import type { ApiResponse } from '../Actions.js'; -class TabbedFeed extends Feed { +export default class TabbedFeed extends Feed { #tabs?: ObservedArray; #actions: Actions; @@ -56,6 +56,4 @@ class TabbedFeed extends Feed { get title(): string | undefined { return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString(); } -} - -export default TabbedFeed; \ No newline at end of file +} \ No newline at end of file diff --git a/src/core/mixins/index.ts b/src/core/mixins/index.ts new file mode 100644 index 00000000..a44eab3e --- /dev/null +++ b/src/core/mixins/index.ts @@ -0,0 +1,4 @@ +export { default as Feed } from './Feed.js'; +export { default as FilterableFeed } from './FilterableFeed.js'; +export { default as TabbedFeed } from './TabbedFeed.js'; +export { default as MediaInfo } from './MediaInfo.js'; \ No newline at end of file diff --git a/src/parser/classes/InfoPanelContent.ts b/src/parser/classes/InfoPanelContent.ts index 1821a7f6..ea33df03 100644 --- a/src/parser/classes/InfoPanelContent.ts +++ b/src/parser/classes/InfoPanelContent.ts @@ -26,7 +26,7 @@ export default class InfoPanelContent extends YTNode { this.truncate_paragraphs = !!data.truncateParagraphs; this.background = data.background; - if (Reflect.has(data.inlineLinkIcon, 'iconType')) { + if (Reflect.has(data, 'inlineLinkIcon') && Reflect.has(data.inlineLinkIcon, 'iconType')) { this.inline_link_icon_type = data.inlineLinkIcon.iconType; } } diff --git a/src/parser/classes/MultiMarkersPlayerBar.ts b/src/parser/classes/MultiMarkersPlayerBar.ts index 37685035..5d0d6416 100644 --- a/src/parser/classes/MultiMarkersPlayerBar.ts +++ b/src/parser/classes/MultiMarkersPlayerBar.ts @@ -19,12 +19,14 @@ export class Marker extends YTNode { this.value = {}; - if (Reflect.has(data.value, 'heatmap')) { - this.value.heatmap = Parser.parseItem(data.value.heatmap, Heatmap); - } + if (Reflect.has(data, 'value')) { + if (Reflect.has(data.value, 'heatmap')) { + this.value.heatmap = Parser.parseItem(data.value.heatmap, Heatmap); + } - if (Reflect.has(data.value, 'chapters')) { - this.value.chapters = Parser.parseArray(data.value.chapters, Chapter); + if (Reflect.has(data.value, 'chapters')) { + this.value.chapters = Parser.parseArray(data.value.chapters, Chapter); + } } } } diff --git a/src/parser/classes/analytics/AnalyticsVodCarouselCard.ts b/src/parser/classes/analytics/AnalyticsVodCarouselCard.ts index d7836aff..24500eef 100644 --- a/src/parser/classes/analytics/AnalyticsVodCarouselCard.ts +++ b/src/parser/classes/analytics/AnalyticsVodCarouselCard.ts @@ -17,7 +17,7 @@ export default class AnalyticsVodCarouselCard extends YTNode { this.no_data_message = data.noDataMessage; } - if (Reflect.has(data.videoCarouselData, 'videos')) { + if (Reflect.has(data, 'videoCarouselData') && Reflect.has(data.videoCarouselData, 'videos')) { this.videos = data.videoCarouselData.videos.map((video: RawNode) => new Video(video)); } } diff --git a/src/parser/classes/comments/CreatorHeart.ts b/src/parser/classes/comments/CreatorHeart.ts index f7d88fc0..f2b74f8d 100644 --- a/src/parser/classes/comments/CreatorHeart.ts +++ b/src/parser/classes/comments/CreatorHeart.ts @@ -21,7 +21,7 @@ export default class CreatorHeart extends YTNode { super(); this.creator_thumbnail = Thumbnail.fromResponse(data.creatorThumbnail); - if (Reflect.has(data.heartIcon, 'iconType')) { + if (Reflect.has(data, 'heartIcon') && Reflect.has(data.heartIcon, 'iconType')) { this.heart_icon_type = data.heartIcon.iconType; } diff --git a/src/parser/classes/comments/PdgCommentChip.ts b/src/parser/classes/comments/PdgCommentChip.ts index 1fcb45d0..6649dfd7 100644 --- a/src/parser/classes/comments/PdgCommentChip.ts +++ b/src/parser/classes/comments/PdgCommentChip.ts @@ -20,7 +20,7 @@ export default class PdgCommentChip extends YTNode { foreground_title_color: data.chipColorPalette?.foregroundTitleColor }; - if (Reflect.has(data.chipIcon, 'iconType')) { + if (Reflect.has(data, 'chipIcon') && Reflect.has(data.chipIcon, 'iconType')) { this.icon_type = data.chipIcon.iconType; } } diff --git a/src/parser/classes/menus/MusicMultiSelectMenu.ts b/src/parser/classes/menus/MusicMultiSelectMenu.ts index a4ad2ae8..2d43c9cc 100644 --- a/src/parser/classes/menus/MusicMultiSelectMenu.ts +++ b/src/parser/classes/menus/MusicMultiSelectMenu.ts @@ -13,7 +13,7 @@ export default class MusicMultiSelectMenu extends YTNode { constructor(data: RawNode) { super(); - if (Reflect.has(data.title, 'musicMenuTitleRenderer')) { + if (Reflect.has(data, 'title') && Reflect.has(data.title, 'musicMenuTitleRenderer')) { this.title = new Text(data.title.musicMenuTitleRenderer?.primaryText); } diff --git a/src/parser/classes/misc/ChildElement.ts b/src/parser/classes/misc/ChildElement.ts index 17357c13..9e138c66 100644 --- a/src/parser/classes/misc/ChildElement.ts +++ b/src/parser/classes/misc/ChildElement.ts @@ -10,7 +10,7 @@ export default class ChildElement extends YTNode { constructor(data: RawNode) { super(); - if (Reflect.has(data.type, 'textType')) { + if (Reflect.has(data, 'type') && Reflect.has(data.type, 'textType')) { this.text = data.type.textType.text?.content; } diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts index c17595fc..b4930250 100644 --- a/src/parser/youtube/Channel.ts +++ b/src/parser/youtube/Channel.ts @@ -1,4 +1,4 @@ -import TabbedFeed from '../../core/TabbedFeed.js'; +import TabbedFeed from '../../core/mixins/TabbedFeed.js'; import C4TabbedHeader from '../classes/C4TabbedHeader.js'; import CarouselHeader from '../classes/CarouselHeader.js'; import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata.js'; @@ -10,8 +10,8 @@ import ExpandableTab from '../classes/ExpandableTab.js'; import SectionList from '../classes/SectionList.js'; import Tab from '../classes/Tab.js'; -import Feed from '../../core/Feed.js'; -import FilterableFeed from '../../core/FilterableFeed.js'; +import Feed from '../../core/mixins/Feed.js'; +import FilterableFeed from '../../core/mixins/FilterableFeed.js'; import ChipCloudChip from '../classes/ChipCloudChip.js'; import FeedFilterChipBar from '../classes/FeedFilterChipBar.js'; import ChannelSubMenu from '../classes/ChannelSubMenu.js'; diff --git a/src/parser/youtube/HashtagFeed.ts b/src/parser/youtube/HashtagFeed.ts index 18a5b125..81113830 100644 --- a/src/parser/youtube/HashtagFeed.ts +++ b/src/parser/youtube/HashtagFeed.ts @@ -1,4 +1,4 @@ -import FilterableFeed from '../../core/FilterableFeed.js'; +import FilterableFeed from '../../core/mixins/FilterableFeed.js'; import { InnertubeError } from '../../utils/Utils.js'; import HashtagHeader from '../classes/HashtagHeader.js'; import RichGrid from '../classes/RichGrid.js'; diff --git a/src/parser/youtube/History.ts b/src/parser/youtube/History.ts index d78e5472..faf81f8c 100644 --- a/src/parser/youtube/History.ts +++ b/src/parser/youtube/History.ts @@ -1,5 +1,5 @@ import type Actions from '../../core/Actions.js'; -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import ItemSection from '../classes/ItemSection.js'; import BrowseFeedActions from '../classes/BrowseFeedActions.js'; import type { IBrowseResponse } from '../types/ParsedResponse.js'; diff --git a/src/parser/youtube/HomeFeed.ts b/src/parser/youtube/HomeFeed.ts index 7c943451..ff3966a6 100644 --- a/src/parser/youtube/HomeFeed.ts +++ b/src/parser/youtube/HomeFeed.ts @@ -1,5 +1,5 @@ import type Actions from '../../core/Actions.js'; -import FilterableFeed from '../../core/FilterableFeed.js'; +import FilterableFeed from '../../core/mixins/FilterableFeed.js'; import ChipCloudChip from '../classes/ChipCloudChip.js'; import FeedTabbedHeader from '../classes/FeedTabbedHeader.js'; import RichGrid from '../classes/RichGrid.js'; diff --git a/src/parser/youtube/Library.ts b/src/parser/youtube/Library.ts index 964c5afd..ffccf517 100644 --- a/src/parser/youtube/Library.ts +++ b/src/parser/youtube/Library.ts @@ -1,7 +1,7 @@ import type Actions from '../../core/Actions.js'; import { InnertubeError } from '../../utils/Utils.js'; -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import History from './History.js'; import Playlist from './Playlist.js'; import Menu from '../classes/menus/Menu.js'; diff --git a/src/parser/youtube/Playlist.ts b/src/parser/youtube/Playlist.ts index 5f4291eb..c813c17e 100644 --- a/src/parser/youtube/Playlist.ts +++ b/src/parser/youtube/Playlist.ts @@ -1,4 +1,4 @@ -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import Message from '../classes/Message.js'; import Thumbnail from '../classes/misc/Thumbnail.js'; import NavigationEndpoint from '../classes/NavigationEndpoint.js'; diff --git a/src/parser/youtube/Search.ts b/src/parser/youtube/Search.ts index b1e2f091..1295b1e5 100644 --- a/src/parser/youtube/Search.ts +++ b/src/parser/youtube/Search.ts @@ -1,4 +1,4 @@ -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import { InnertubeError } from '../../utils/Utils.js'; import HorizontalCardList from '../classes/HorizontalCardList.js'; import ItemSection from '../classes/ItemSection.js'; diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index 07532215..2deb7f6e 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -32,7 +32,7 @@ import type { ApiResponse } from '../../core/Actions.js'; import { ObservedArray, YTNode } from '../helpers.js'; import { InnertubeError } from '../../utils/Utils.js'; -import { MediaInfo } from '../../core/MediaInfo.js'; +import { MediaInfo } from '../../core/mixins/index.js'; class VideoInfo extends MediaInfo { #watch_next_continuation?: ContinuationItem; diff --git a/src/parser/ytkids/Channel.ts b/src/parser/ytkids/Channel.ts index 73d89988..644577cf 100644 --- a/src/parser/ytkids/Channel.ts +++ b/src/parser/ytkids/Channel.ts @@ -1,4 +1,4 @@ -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import C4TabbedHeader from '../classes/C4TabbedHeader.js'; import ItemSection from '../classes/ItemSection.js'; import { ItemSectionContinuation } from '../index.js'; diff --git a/src/parser/ytkids/HomeFeed.ts b/src/parser/ytkids/HomeFeed.ts index 406ba101..09e7bf69 100644 --- a/src/parser/ytkids/HomeFeed.ts +++ b/src/parser/ytkids/HomeFeed.ts @@ -1,4 +1,4 @@ -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import KidsCategoriesHeader from '../classes/ytkids/KidsCategoriesHeader.js'; import KidsCategoryTab from '../classes/ytkids/KidsCategoryTab.js'; import KidsHomeScreen from '../classes/ytkids/KidsHomeScreen.js'; diff --git a/src/parser/ytkids/Search.ts b/src/parser/ytkids/Search.ts index fd39d871..e758adfe 100644 --- a/src/parser/ytkids/Search.ts +++ b/src/parser/ytkids/Search.ts @@ -1,4 +1,4 @@ -import Feed from '../../core/Feed.js'; +import Feed from '../../core/mixins/Feed.js'; import ItemSection from '../classes/ItemSection.js'; import { InnertubeError } from '../../utils/Utils.js'; import type Actions from '../../core/Actions.js'; diff --git a/src/parser/ytkids/VideoInfo.ts b/src/parser/ytkids/VideoInfo.ts index ee53efae..f04a457c 100644 --- a/src/parser/ytkids/VideoInfo.ts +++ b/src/parser/ytkids/VideoInfo.ts @@ -7,7 +7,7 @@ import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults.js'; import type Actions from '../../core/Actions.js'; import type { ApiResponse } from '../../core/Actions.js'; import type { ObservedArray, YTNode } from '../helpers.js'; -import { MediaInfo } from '../../core/MediaInfo.js'; +import { MediaInfo } from '../../core/mixins/index.js'; class VideoInfo extends MediaInfo { basic_info; diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts index b62ab7f8..d2f26a4a 100644 --- a/src/parser/ytmusic/TrackInfo.ts +++ b/src/parser/ytmusic/TrackInfo.ts @@ -22,7 +22,7 @@ import type NavigationEndpoint from '../classes/NavigationEndpoint.js'; import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.js'; import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js'; import type { ObservedArray, YTNode } from '../helpers.js'; -import { MediaInfo } from '../../core/MediaInfo.js'; +import { MediaInfo } from '../../core/mixins/index.js'; class TrackInfo extends MediaInfo { basic_info; diff --git a/src/proto/index.ts b/src/proto/index.ts index 767b2ba1..bd570b7d 100644 --- a/src/proto/index.ts +++ b/src/proto/index.ts @@ -1,6 +1,6 @@ import { CLIENTS } from '../utils/Constants.js'; import { base64ToU8, u8ToBase64 } from '../utils/Utils.js'; -import { VideoMetadata } from '../core/Studio.js'; +import { UpdateVideoMetadataOptions } from '../types/index.js'; import * as VisitorData from './generated/messages/youtube/VisitorData.js'; import * as ChannelAnalytics from './generated/messages/youtube/ChannelAnalytics.js'; @@ -235,7 +235,7 @@ class Proto { return encodeURIComponent(u8ToBase64(buf)); } - static encodeVideoMetadataPayload(video_id: string, metadata: VideoMetadata): Uint8Array { + static encodeVideoMetadataPayload(video_id: string, metadata: UpdateVideoMetadataOptions): Uint8Array { const data: InnertubePayload.Type = { context: { client: { diff --git a/src/types/Clients.ts b/src/types/Clients.ts new file mode 100644 index 00000000..573d1541 --- /dev/null +++ b/src/types/Clients.ts @@ -0,0 +1,23 @@ +// Studio.ts +export type UpdateVideoMetadataOptions = Partial<{ + title: string; + description: string; + tags: string[]; + category: number; + license: string; + age_restricted: boolean; + made_for_kids: boolean; + privacy: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; +}>; + +export type UploadedVideoMetadataOptions = Partial<{ + title: string; + description: string; + privacy: 'PUBLIC' | 'PRIVATE' | 'UNLISTED'; + is_draft: boolean; +}>; + +// Music.ts +export type MusicSearchFilters = Partial<{ + type: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'; +}>; \ No newline at end of file diff --git a/src/types/Endpoints.ts b/src/types/Endpoints.ts new file mode 100644 index 00000000..ef31d187 --- /dev/null +++ b/src/types/Endpoints.ts @@ -0,0 +1,343 @@ +import type { InnerTubeClient } from '../Innertube.js'; + +export type SnakeToCamel = S extends `${infer T}_${infer U}` ? `${Lowercase}${Capitalize>}` : S; + +export type ObjectSnakeToCamel = { + [K in keyof T as SnakeToCamel]: T[K] extends object ? ObjectSnakeToCamel : T[K]; +} + +export interface IPlayerRequest { + playbackContext: { + contentPlaybackContext: { + vis: number; + splay: boolean; + referer: string; + currentUrl: string; + autonavState: string; + signatureTimestamp?: number; + autoCaptionsDefaultOn: boolean; + html5Preference: string; + lactMilliseconds: string; + } + }, + videoId: string; + racyCheckOk: boolean; + contentCheckOk: boolean; + client?: InnerTubeClient; + playlistId?: string; +} + +export type PlayerEndpointOptions = { + /** + * The video ID. + */ + video_id: string; + /** + * The player's signature timestamp. + */ + sts?: number; + /** + * The client to use. + */ + client?: InnerTubeClient; + /** + * The playlist ID. + */ + playlist_id?: string; +} + +export type NextEndpointOptions = { + /** + * The video ID. + */ + video_id?: string; + /** + * The playlist associated with the video. + */ + playlist_id?: string; + /** + * Protobuf parameters. + */ + params?: string; + /** + * The playlist index. + */ + playlist_index?: number; + /** + * The client to use. + */ + client?: InnerTubeClient; + /** + * The continuation token. Mostly used for pagination. + */ + continuation?: string; +} + +export type INextRequest = ObjectSnakeToCamel; + +export type BrowseEndpointOptions = { + /** + * The browse ID. + */ + browse_id?: string; + /** + * Additional protobuf parameters. + */ + params?: string; + /** + * The continuation token. Mostly used for pagination. + */ + continuation?: string; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type IBrowseRequest = ObjectSnakeToCamel; + +export interface ISearchRequest { + /** + * The query to search for. + */ + query?: string; + /** + * Additional protobuf parameters. + */ + params?: string; + /** + * The continuation token. Mostly sed for pagination. + */ + continuation?: string; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type SearchEndpointOptions = ISearchRequest; + +export interface IResolveURLRequest { + /** + * The URL to resolve. + */ + url: string; +} + +export type ResolveURLEndpointOptions = IResolveURLRequest; + +export type GetNotificationMenuEndpointOptions = { + /** + * The type of notifications to request. + */ + notifications_menu_request_type: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' | 'NOTIFICATIONS_MENU_REQUEST_TYPE_COMMENTS'; +} + +export type IGetNotificationMenuRequest = ObjectSnakeToCamel; + +export type MusicGetSearchSuggestionsEndpointOptions = { + /** + * The query to search for. + */ + input: string; +} + +export interface IMusicGetSearchSuggestionsRequest extends MusicGetSearchSuggestionsEndpointOptions { + client: 'YTMUSIC'; +} + +export type ChannelEditNameEndpointOptions = { + /** + * The new channel name. + */ + given_name: string; +} + +export interface IChannelEditNameRequest extends ObjectSnakeToCamel { + client: 'ANDROID'; +} + +export type ChannelEditDescriptionEndpointOptions = { + /** + * The new channel description. + */ + given_description: string; +} + +export interface IChannelEditDescriptionRequest extends ObjectSnakeToCamel { + client: 'ANDROID'; +} + +export interface IAccountListRequest { + client: 'ANDROID'; +} + +export type LikeEndpointOptions = { + /** + * The client to use. + */ + client?: InnerTubeClient; + /** + * The target video. + */ + target: { + video_id: string; + } +} + +export type ILikeRequest = ObjectSnakeToCamel; + +export type IDislikeRequest = ILikeRequest; +export type DislikeEndpointOptions = LikeEndpointOptions; + +export type IRemoveLikeRequest = ILikeRequest; +export type RemoveLikeEndpointOptions = LikeEndpointOptions; + +export type SubscribeEndpointOptions = { + /** + * The channel IDs to subscribe to/unsubscribe from. + */ + channel_ids: string[]; + /** + * Additional protobuf parameters. + */ + params?: string; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type ISubscribeRequest = ObjectSnakeToCamel; + +export type IUnsubscribeRequest = ISubscribeRequest; +export type UnsubscribeEndpointOptions = SubscribeEndpointOptions; + +export interface IPerformCommentActionRequest { + /** + * An array of protobuf-encoded actions. + */ + actions: string[]; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type PerformCommentActionEndpointOptions = IPerformCommentActionRequest; + +export type CreateCommentEndpointOptions = { + /** + * The comment text. + */ + comment_text: string; + /** + * Additional protobuf parameters. + */ + create_comment_params: string; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type ICreateCommentRequest = ObjectSnakeToCamel; + +export interface IModifyChannelPreferenceRequest { + /** + * Protobuf-encoded parameters. + */ + params: string; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type ModifyChannelPreferenceEndpointOptions = IModifyChannelPreferenceRequest; + +export type CreateVideoEndpointOptions = { + /** + * The id of the uploaded resource. + */ + resource_id: { + scotty_resource_id: { + id: string; + } + }; + /** + * The id of the frontend. + */ + frontend_upload_id: string; + /** + * The metadata to set after the video is uploaded. + */ + initial_metadata: { + title: { + new_title: string; + }; + description: { + new_description: string; + should_segment: boolean; + }; + privacy: { + new_privacy: string; + }; + draft_state: { + is_draft?: boolean; + }; + }; + /** + * The client to use. + */ + client?: InnerTubeClient; +} + +export type ICreateVideoRequest = ObjectSnakeToCamel; + +export type CreatePlaylistEndpointOptions = { + /** + * The playlist title. + */ + title: string; + /** + * The video IDs to add to the playlist. + */ + ids: string[]; +} + +export type ICreatePlaylistRequest = CreatePlaylistEndpointOptions; + +export type DeletePlaylistEndpointOptions = { + /** + * The ID of the playlist to delete. + */ + playlist_id: string; +} + +export type IDeletePlaylistRequest = ObjectSnakeToCamel; + +export type EditPlaylistEndpointOptions = { + /** + * The ID of the playlist to edit. + */ + playlist_id: string; + /** + * The changes to make to the playlist. + */ + actions: { + action: 'ACTION_ADD_VIDEO' | 'ACTION_REMOVE_VIDEO' | 'ACTION_MOVE_VIDEO_AFTER'; + added_video_id?: string; + set_video_id?: string; + moved_set_video_id_predecessor?: string; + }[]; +} + +export interface IEditPlaylistRequest extends ObjectSnakeToCamel { + actions: { + action: 'ACTION_ADD_VIDEO' | 'ACTION_REMOVE_VIDEO' | 'ACTION_MOVE_VIDEO_AFTER'; + addedVideoId?: string; + setVideoId?: string; + movedSetVideoIdPredecessor?: string; + }[]; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b7e2ed88..63ea16ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ -export * from './Cache.js'; - export type { default as PlatformShim } from './PlatformShim.js'; + +export * from './Cache.js'; export * from './PlatformShim.js'; +export * from './Clients.js'; +export * from './Endpoints.js'; \ No newline at end of file diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index 8cccab35..482d2b1b 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -54,9 +54,9 @@ export default class HTTPClient { request_headers.set('Accept', '*/*'); request_headers.set('Accept-Language', '*'); - 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 || ''); + 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 || ''); if (Platform.shim.server) { request_headers.set('User-Agent', getRandomUserAgent('desktop')); @@ -91,7 +91,6 @@ export default class HTTPClient { delete n_body.client; - if (Platform.shim.server) { if (n_body.context.client.clientName === 'ANDROID' || n_body.context.client.clientName === 'ANDROID_MUSIC') { request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT); @@ -143,6 +142,19 @@ export default class HTTPClient { } #adjustContext(ctx: Context, client: string): void { + if ( + client === 'ANDROID' || + client === 'YTMUSIC_ANDROID' || + client === 'YTMUSIC_ANDROID' || + client === 'YTSTUDIO_ANDROID' + ) { + ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; + ctx.client.userAgent = Constants.CLIENTS.ANDROID.USER_AGENT; + ctx.client.osName = 'Android'; + ctx.client.osVersion = '10'; + ctx.client.platform = 'MOBILE'; + } + switch (client) { case 'YTMUSIC': ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; @@ -152,21 +164,16 @@ export default class HTTPClient { ctx.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION; ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR'; ctx.client.clientName = Constants.CLIENTS.ANDROID.NAME; - ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; - ctx.client.platform = 'MOBILE'; break; case 'YTMUSIC_ANDROID': ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC_ANDROID.VERSION; ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR'; ctx.client.clientName = Constants.CLIENTS.YTMUSIC_ANDROID.NAME; - ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; - ctx.client.platform = 'MOBILE'; break; case 'YTSTUDIO_ANDROID': ctx.client.clientVersion = Constants.CLIENTS.YTSTUDIO_ANDROID.VERSION; ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR'; ctx.client.clientName = Constants.CLIENTS.YTSTUDIO_ANDROID.NAME; - ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; break; case 'TV_EMBEDDED': ctx.client.clientName = Constants.CLIENTS.TV_EMBEDDED.NAME; diff --git a/test/main.test.ts b/test/main.test.ts index 1322864d..4db6a96b 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,5 +1,5 @@ import { createWriteStream, existsSync } from 'node:fs'; -import { Innertube, IBrowseResponse, TabbedFeed, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs'; +import { Innertube, IBrowseResponse, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs'; describe('YouTube.js Tests', () => { let innertube: Innertube; @@ -94,11 +94,6 @@ describe('YouTube.js Tests', () => { expect(home_feed.contents.contents?.length).toBeGreaterThan(0); }); - test('HomeFeed#applyFilter', async () => { - const filtered_home_feed = await home_feed.applyFilter(home_feed.filter_chips[1]); - expect(filtered_home_feed.contents.contents?.length).toBeGreaterThan(0); - }); - test('HomeFeed#getContinuation', async () => { const incremental_continuation = await home_feed.getContinuation(); expect(incremental_continuation.contents).toBeDefined(); @@ -118,7 +113,7 @@ describe('YouTube.js Tests', () => { expect(trending).toBeDefined(); expect(trending.page.contents).toBeDefined(); expect(trending.page.contents_memo).toBeDefined(); - expect(trending.videos.length).toBeGreaterThan(0); TabbedFeed; + expect(trending.videos.length).toBeGreaterThan(0); }); describe('Innertube#getChannel', () => {