commit 52b74004421cafc50081bc1ef970d0cfa4e887c1 Author: LuanRT Date: Fri Feb 17 04:23:49 2023 +0000 chore: v3.0.0 release diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3659f9e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 LuanRT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..cd6db460 --- /dev/null +++ b/README.md @@ -0,0 +1,759 @@ + +[npm]: https://www.npmjs.com/package/youtubei.js +[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions +[codefactor]: https://www.codefactor.io/repository/github/luanrt/youtube.js +[actions]: https://github.com/LuanRT/YouTube.js/actions +[say-thanks]: https://saythanks.io/to/LuanRT +[github-sponsors]:https://github.com/sponsors/LuanRT + + +[project]: https://github.com/LuanRT/YouTube.js +[twitter]: https://twitter.com/thesciencephile +[discord]: https://discord.gg/syDu7Yks54 +[nodejs]: https://nodejs.org + +

YouTube.js

+ +

A full-featured wrapper around the InnerTube API

+ +
+ + [![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions] + [![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions] + [![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor] + [![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm] + [![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord] + [![Say thanks](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)][say-thanks] +
+ [![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][github-sponsors] + +
+ +

+ Special thanks to: +

+ + + + + + + +
+ + SerpApi +
+ + + Scrape Google and other search engines from a fast, easy and complete API. + + +
+
+ +___ + +
+ Table of Contents +
    +
  1. + About +
  2. +
  3. + Getting Started + +
  4. +
  5. + Usage + +
  6. +
  7. Extending the library
  8. +
  9. Contributing
  10. +
  11. Contact
  12. +
  13. Disclaimer
  14. +
  15. License
  16. +
+
+ +## Description + +InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform[^1]. This library handles all the low-level communication with InnerTube, providing a simple, and efficient way to interact with YouTube programmatically. It is designed to emulate an actual client as closely as possible, including how API responses are parsed. + +If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues). + +## Getting Started + +### Prerequisites +YouTube.js runs on Node.js, Deno, and modern browsers. + +It requires a runtime with the following features: +- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) + - On Node we use [undici]()'s fetch implementation which requires Node.js 16.8+. You may provide your fetch implementation if you need to use an older version. See [providing your own fetch implementation](#custom-fetch) for more information. + - The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` returns a non-standard `Readable` object.) +- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required. + +### Installation + +```bash +# NPM +npm install youtubei.js@latest + +# Yarn +yarn add youtubei.js@latest + +# Git (edge version) +npm install github:LuanRT/YouTube.js +``` + +**TODO:** Deno install instructions (deno.land) + +## Usage +Create an InnerTube instance: +```ts +// const { Innertube } = require('youtubei.js'); +import { Innertube } from 'youtubei.js'; +const youtube = await Innertube.create(); +``` + +## Browser Usage +To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts). + +You may provide your own fetch implementation to be used by YouTube.js. Which we will use here to modify and send the requests through our proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/). + +```ts +// Pre-bundled version for the web +import { Innertube } from 'youtubei.js/bundle/browser'; +await Innertube.create({ + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + // Modify the request + // and send it to the proxy + + // fetch the URL + return fetch(request, init); + } +}); +``` + +### Streaming +YouTube.js supports streaming of videos in the browser by converting YouTube's streaming data into an MPEG-DASH manifest. + +The example below uses [`dash.js`](https://github.com/Dash-Industry-Forum/dash.js) to play the video. + +```ts +import { Innertube } from 'youtubei.js'; +import dashjs from 'dashjs'; + +const youtube = await Innertube.create({ /* setup - see above */ }); + +// get the video info +const videoInfo = await youtube.getInfo('videoId'); + +// now convert to a dash manifest +// again - to be able to stream the video in the browser - we must proxy the requests through our own server +// to do this, we provide a method to transform the URLs before writing them to the manifest +const manifest = videoInfo.toDash(url => { + // modify the url + // and return it + return url; +}); + +const uri = "data:application/dash+xml;charset=utf-8;base64," + btoa(manifest); + +const videoElement = document.getElementById('video_player'); + +const player = dashjs.MediaPlayer().create(); +player.initialize(videoElement, uri, true); +``` + +A fully working example can be found in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web). Alternatively, you can view it live at [ytjsexample.pages.dev](https://ytjsexample.pages.dev/). + + + +## Providing your own fetch implementation +You may provide your own fetch implementation to be used by YouTube.js. This can be useful in some cases to modify the requests before they are sent and transform the responses before they are returned (eg. for proxies). +```ts +// provide a fetch implementation +const yt = await Innertube.create({ + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + // make the request with your own fetch implementation + // and return the response + return new Response( + /* ... */ + ); + } +}); +``` + + + +## Caching +To improve performance, you may wish to cache the transformed player instance which we use to decode the streaming urls. + +Our cache uses the `node:fs` module in Node-like environments, `Deno.writeFile` in Deno, and `indexedDB` in browsers. + +```ts +import { Innertube, UniversalCache } from 'youtubei.js'; +// By default, cache stores files in the OS temp directory (or indexedDB in browsers). +const yt = await Innertube.create({ + cache: new UniversalCache() +}); + +// You may wish to make the cache persistent (on Node and Deno) +const yt = await Innertube.create({ + cache: new UniversalCache( + // Enables persistent caching + true, + // Path to the cache directory will create the directory if it doesn't exist + './.cache' + ) +}); +``` + +## API + +* `Innertube` + +
+ Objects +

+ + * [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md) + * [.account](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/account.md) + * [.interact](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/interaction-manager.md) + * [.playlist](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/playlist.md) + * [.music](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/music.md) + * [.studio](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/studio.md) + * [.kids](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/kids.md) + +

+
+ + +
+ Methods +

+ + * [.getInfo(video_id, client?)](#getinfo) + * [.getBasicInfo(video_id, client?)](#getbasicinfo) + * [.search(query, filters?)](#search) + * [.getSearchSuggestions(query)](#getsearchsuggestions) + * [.getComments(video_id, sort_by?)](#getcomments) + * [.getHomeFeed()](#gethomefeed) + * [.getLibrary()](#getlibrary) + * [.getHistory()](#gethistory) + * [.getTrending()](#gettrending) + * [.getSubscriptionsFeed()](#getsubscriptionsfeed) + * [.getChannel(id)](#getchannel) + * [.getNotifications()](#getnotifications) + * [.getUnseenNotificationsCount()](#getunseennotificationscount) + * [.getPlaylist(id)](#getplaylist) + * [.getHashtag(hashtag)](#gethashtag) + * [.getStreamingData(video_id, options)](#getstreamingdata) + * [.download(video_id, options?)](#download) + * [.resolveURL(url)](#resolveurl) + * [.call(endpoint, args?)](#call) + +

+
+ + +### getInfo(video_id, client?) + +Retrieves video info, including playback data and even layout elements such as menus, buttons, etc — all nicely parsed. + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| video_id | `string` | The id of the video | +| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` | + +
+Methods & Getters +

+ +- `#like()` + - Likes the video. + +- `#dislike()` + - Dislikes the video. + +- `#removeRating()` + - Removes like/dislike. + +- `#getLiveChat()` + - Returns a LiveChat instance. + +- `#chooseFormat(options)` + - Used to choose streaming data formats. + +- `#toDash(url_transformer?, format_filter?)` + - Converts streaming data to an MPEG-DASH manifest. + +- `#download(options)` + - Downloads the video. See [download](#download). + +- `#filters` + - Returns filters that can be applied to the watch next feed. + +- `#selectFilter(name)` + - Applies the given filter to the watch next feed and returns a new instance of [`VideoInfo`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/VideoInfo.ts). + +- `#getWatchNextContinuation()` + - Retrieves the next batch of items for the watch next feed. + +- `#addToWatchHistory()` + - Adds the video to the watch history. + +- `#page` + - Returns original InnerTube response (sanitized). + +

+
+ + +### getBasicInfo(video_id, client?) + +Suitable for cases where you only need basic video metadata. Also, it is faster than [`getInfo()`](#getinfo). + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| video_id | `string` | The id of the video | +| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` | + + +### search(query, filters?) + +Searches the given query on YouTube. + +**Returns**: `Promise` + +> **Note** +> `Search` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class. + +| Param | Type | Description | +| --- | --- | --- | +| query | `string` | The search query | +| filters? | `SearchFilters` | Search filters | + +
+Methods & Getters +

+ +- `#selectRefinementCard(SearchRefinementCard | string)` + - Applies given refinement card and returns a new Search instance. + +- `#refinement_card_queries` + - Returns available refinement cards, this is a simplified version of the `refinement_cards` object. + +- `#getContinuation()` + - Retrieves next batch of results. + +

+
+ + +### getSearchSuggestions(query) +Retrieves search suggestions for given query. + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| query | `string` | The search query | + + +### getComments(video_id, sort_by?) +Retrieves comments for given video. + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| video_id | `string` | The video id | +| sort_by | `string` | Can be: `TOP_COMMENTS` or `NEWEST_FIRST` | + +See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examples/comments) for examples. + + +### getHomeFeed() +Retrieves YouTube's home feed. + +**Returns**: `Promise` + +> **Note** +> `HomeFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class. + +
+Methods & Getters +

+ +- `#videos` + - Returns all videos in the home feed. + +- `#posts` + - Returns all posts in the home feed. + +- `#shelves` + - Returns all shelves in the home feed. + +- `#filters` + - Returns available filters. + +- `#applyFilter(name | ChipCloudChip)` + - Applies given filter and returns a new HomeFeed instance. + +- `#getContinuation()` + - Retrieves feed continuation. + +

+
+ + +### getLibrary() +Retrieves the account's library. + +**Returns**: `Promise` + +> **Note** +> `Library` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class. + +
+Methods & Getters +

+ +- `#history` +- `#watch_later` +- `#liked_videos` +- `#playlists_section` +- `#clips` + +

+
+ + +### getHistory() +Retrieves watch history. + +**Returns**: `Promise` + +> **Note** +> `History` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class. + +
+Methods & Getters +

+ +- `#getContinuation()` + - Retrieves next batch of contents. + +

+
+ + +### getTrending() +Retrieves trending content. + +**Returns**: `Promise>` + + +### getSubscriptionsFeed() +Retrieves the subscriptions feed. + +**Returns**: `Promise>` + + +### getChannel(id) +Retrieves contents for a given channel. + +**Returns**: `Promise` + +> **Note** +> `Channel` extends the [`TabbedFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/tabbed-feed.md) class. + +| Param | Type | Description | +| --- | --- | --- | +| id | `string` | Channel id | + +
+Methods & Getters +

+ +- `#getVideos()` +- `#getShorts()` +- `#getLiveStreams()` +- `#getPlaylists()` +- `#getHome()` +- `#getCommunity()` +- `#getChannels()` +- `#getAbout()` +- `#search(query)` +- `#applyFilter(filter)` +- `#applyContentTypeFilter(content_type_filter)` +- `#applySort(sort)` +- `#getContinuation()` +- `#filters` +- `#content_type_filters` +- `#sort_filters` +- `#page` + +

+
+ +See [`./examples/channel`](https://github.com/LuanRT/YouTube.js/blob/main/examples/channel) for examples. + + +### getNotifications() +Retrieves notifications. + +**Returns**: `Promise` + +
+Methods & Getter +

+ +- `#getContinuation()` + - Retrieves next batch of notifications. + +

+
+ + +### getUnseenNotificationsCount() +Retrieves unseen notifications count. + +**Returns**: `Promise` + + +### getPlaylist(id) +Retrieves playlist contents. + +**Returns**: `Promise` + +> **Note** +> `Playlist` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class. + +| Param | Type | Description | +| --- | --- | --- | +| id | `string` | Playlist id | + +
+Methods & Getter +

+ +- `#items` + - Returns the items of the playlist. + +

+
+ + +### getHashtag(hashtag) +Retrieves a given hashtag's page. + +**Returns**: `Promise` + +> **Note** +> `HashtagFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class. + +| Param | Type | Description | +| --- | --- | --- | +| hashtag | `string` | The hashtag | + +
+Methods & Getter +

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

+
+ + +### getStreamingData(video_id, options) +Returns deciphered streaming data. + +> **Note** +> This will be deprecated in the future. It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, see the example below. + +```ts +const info = await yt.getBasicInfo('somevideoid'); +const url = info.streaming_data?.formats[0].decipher(yt.session.player); +console.info('Playback url:', url); +``` + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| video_id | `string` | Video id | +| options | `FormatOptions` | Format options | + + +### download(video_id, options?) +Downloads a given video. + +**Returns**: `Promise>` + +| Param | Type | Description | +| --- | --- | --- | +| video_id | `string` | Video id | +| options | `DownloadOptions` | Download options | + +See [`./examples/download`](https://github.com/LuanRT/YouTube.js/blob/main/examples/download) for examples. + + +### resolveURL(url) +Resolves a given url. + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| url | `string` | Url to resolve | + + +### call(endpoint, args?) +Utility to call navigation endpoints. + +**Returns**: `Promise` + +| Param | Type | Description | +| --- | --- | --- | +| endpoint | `NavigationEndpoint` | The target endpoint | +| args? | `object` | Additional payload arguments | + +## Extending the library + +YouTube.js is completely modular and easy to extend. Almost all methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code. + +For example, let's say we want to implement a method to retrieve video info manually. We can do that by using an instance of the `Actions` class: + +```ts +import { Innertube } from 'youtubei.js'; + +(async () => { + const yt = await Innertube.create(); + + async function getVideoInfo(videoId: string) { + const videoInfo = await yt.actions.execute('/player', { + // anything added here will be merged with the default payload and sent to InnerTube. + videoId, + client: 'YTMUSIC', // InnerTube client, can be ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB or TV_EMBEDDED + parse: true // tells YouTube.js to parse the response, this is not sent to InnerTube. + }); + + return videoInfo; + } + + const videoInfo = await getVideoInfo('jLTOuvBTLxA'); + console.info(videoInfo); +})(); +``` + +Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens: + +```ts +import { Innertube, YTNodes } from 'youtubei.js'; + +(async () => { + const yt = await Innertube.create(); + + const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g'); + const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf); + + // Say we want to click the “More” button: + const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content; + + if (button) { + // After making sure it exists, we can call its navigation endpoint: + const page = await button.endpoint.call(yt.actions, { parse: true }); + console.info(page); + } +})(); +``` + +### Parser + +YouTube.js' parser allows you to parse InnerTube responses and turn their nodes into strongly typed objects that can be easily manipulated. It also provides a set of utility methods that make working with InnerTube much easier. + +Example: +```ts +// See ./examples/parser + +import { Parser, YTNodes } from 'youtubei.js'; +import { readFileSync } from 'fs'; + +// Artist page response from YouTube Music +const data = readFileSync('./artist.json').toString(); + +const page = Parser.parseResponse(JSON.parse(data)); + +const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader); + +console.info('Header:', header); + +/** + * The parser encapsulates all arrays in a proxy object. + * A proxy intercepts access to the actual data, allowing + * the parser to add type safety and many utility methods + * that make working with InnerTube much easier. + */ +const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab); + + +if (!tab) + throw new Error('Target tab not found'); + +if (!tab.content) + throw new Error('Target tab appears to be empty'); + +const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf); + +console.info('Sections:', sections); +``` + +Documentation for the parser can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser). + + +## Contributing +Contributions, issues, and feature requests are welcome. +Feel free to check the [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md) if you want to contribute. + +Thank you to all the wonderful people who have contributed to this project: + + + + +## Contact + +LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com + +Project Link: [https://github.com/LuanRT/YouTube.js][project] + +## Disclaimer +This project is not affiliated with, endorsed, or sponsored by YouTube or any of its affiliates or subsidiaries. +All trademarks, logos, and brand names are the property of their respective owners and are used only to directly describe the services being provided, as such, any usage of trademarks to refer to such services is considered nominative use. + +Should you have any questions or concerns please contact me directly via email. + +[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491 + +## License +Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. + +

+(back to top) +

diff --git a/deno.ts b/deno.ts new file mode 100644 index 00000000..1225fd4b --- /dev/null +++ b/deno.ts @@ -0,0 +1,3 @@ +export * from './deno/src/platform/deno.ts'; +import Innertube from './deno/src/platform/deno.ts'; +export default Innertube; \ No newline at end of file diff --git a/deno/package.json b/deno/package.json new file mode 100644 index 00000000..14fdfe4b --- /dev/null +++ b/deno/package.json @@ -0,0 +1,132 @@ +{ + "name": "youtubei.js", + "version": "3.0.0", + "description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).", + "type": "module", + "types": "./dist/src/platform/lib.d.ts", + "typesVersions": { + "*": { + "agnostic": [ + "./dist/src/platform/lib.d.ts" + ], + "web": [ + "./dist/src/platform/lib.d.ts" + ], + "web.bundle": [ + "./dist/src/platform/lib.d.ts" + ], + "web.bundle.min": [ + "./dist/src/platform/lib.d.ts" + ] + } + }, + "exports": { + ".": { + "node": { + "import": "./dist/src/platform/node.js", + "require": "./bundle/node.cjs" + }, + "deno": "./dist/src/platform/deno.js", + "types": "./dist/src/platform/lib.d.ts", + "browser": "./dist/src/platform/web.js", + "default": "./dist/src/platform/web.js" + }, + "./agnostic": { + "types": "./dist/src/platform/lib.d.ts", + "default": "./dist/src/platform/lib.js" + }, + "./web": { + "types": "./dist/src/platform/lib.d.ts", + "default": "./dist/src/platform/web.js" + }, + "./web.bundle": { + "types": "./dist/src/platform/lib.d.ts", + "default": "./bundle/browser.js" + }, + "./web.bundle.min": { + "types": "./dist/src/platform/lib.d.ts", + "default": "./bundle/browser.min.js" + } + }, + "author": "LuanRT (https://github.com/LuanRT)", + "funding": [ + "https://github.com/sponsors/LuanRT" + ], + "contributors": [ + "Wykerd (https://github.com/wykerd/)", + "MasterOfBob777 (https://github.com/MasterOfBob777)", + "patrickkfkan (https://github.com/patrickkfkan)", + "akkadaska (https://github.com/akkadaska)" + ], + "directories": { + "test": "./test", + "examples": "./examples", + "dist": "./dist" + }, + "scripts": { + "test": "npx jest --verbose", + "lint": "npx eslint ./src", + "lint:fix": "npx eslint --fix ./src", + "build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod", + "build:parser-map": "node ./scripts/build-parser-map.cjs", + "build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"", + "build:esm": "npx tsc", + "build:deno": "npx cpy ./src ./deno && npx cpy ./package.json ./deno && npx replace \".ts';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'https://esm.sh/linkedom';\" \"'https://esm.sh/linkedom';\" ./deno -r && npx replace \"'https://esm.sh/jintr';\" \"'https://esm.sh/jintr';\" ./deno -r && npx replace \"new Jinter\" \"new Jinter\" ./deno -r", + "bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --sourcemap --banner:js=\"/* eslint-disable */\"", + "bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --outfile=./bundle/browser.js --platform=browser", + "bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify", + "prepare": "npm run build", + "watch": "npx tsc --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/LuanRT/YouTube.js.git" + }, + "license": "MIT", + "dependencies": { + "jintr": "^0.4.1", + "linkedom": "^0.14.12", + "undici": "^5.19.1" + }, + "devDependencies": { + "@types/jest": "^28.1.7", + "@types/node": "^17.0.45", + "@typescript-eslint/eslint-plugin": "^5.30.6", + "@typescript-eslint/parser": "^5.30.6", + "cpy-cli": "^4.2.0", + "esbuild": "^0.14.49", + "eslint": "^8.19.0", + "eslint-plugin-tsdoc": "^0.2.16", + "glob": "^8.0.3", + "jest": "^28.1.3", + "pbkit": "^0.0.59", + "replace": "^1.2.2", + "ts-jest": "^28.0.8", + "typescript": "^4.9.5" + }, + "bugs": { + "url": "https://github.com/LuanRT/YouTube.js/issues" + }, + "homepage": "https://github.com/LuanRT/YouTube.js#readme", + "keywords": [ + "yt", + "dl", + "ytdl", + "youtube", + "youtubedl", + "youtube-dl", + "youtube-downloader", + "youtube-music", + "youtube-studio", + "innertube", + "unofficial", + "downloader", + "livechat", + "studio", + "upload", + "ytmusic", + "search", + "music", + "api" + ] +} diff --git a/deno/src/Innertube.ts b/deno/src/Innertube.ts new file mode 100644 index 00000000..11c9ba61 --- /dev/null +++ b/deno/src/Innertube.ts @@ -0,0 +1,307 @@ + +import Session, { SessionOptions } from './core/Session.ts'; + +import NavigationEndpoint from './parser/classes/NavigationEndpoint.ts'; +import Channel from './parser/youtube/Channel.ts'; +import Comments from './parser/youtube/Comments.ts'; +import History from './parser/youtube/History.ts'; +import Library from './parser/youtube/Library.ts'; +import NotificationsMenu from './parser/youtube/NotificationsMenu.ts'; +import Playlist from './parser/youtube/Playlist.ts'; +import Search from './parser/youtube/Search.ts'; +import VideoInfo from './parser/youtube/VideoInfo.ts'; +import HashtagFeed from './parser/youtube/HashtagFeed.ts'; + +import AccountManager from './core/AccountManager.ts'; +import Feed from './core/Feed.ts'; +import InteractionManager from './core/InteractionManager.ts'; +import YTKids from './core/Kids.ts'; +import YTMusic from './core/Music.ts'; +import PlaylistManager from './core/PlaylistManager.ts'; +import YTStudio from './core/Studio.ts'; +import TabbedFeed from './core/TabbedFeed.ts'; +import HomeFeed from './parser/youtube/HomeFeed.ts'; +import Proto from './proto/index.ts'; +import Constants from './utils/Constants.ts'; + +import type Actions from './core/Actions.ts'; +import type Format from './parser/classes/misc/Format.ts'; + +import type { ApiResponse } from './core/Actions.ts'; +import type { IBrowseResponse, IParsedResponse } from './parser/types/index.ts'; +import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.ts'; +import { generateRandomString, throwIfMissing } from './utils/Utils.ts'; + +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' + +class Innertube { + session: Session; + account: AccountManager; + playlist: PlaylistManager; + interact: InteractionManager; + music: YTMusic; + studio: YTStudio; + kids: YTKids; + actions: Actions; + + constructor(session: Session) { + this.session = session; + this.account = new AccountManager(this.session.actions); + this.playlist = new PlaylistManager(this.session.actions); + this.interact = new InteractionManager(this.session.actions); + this.music = new YTMusic(this.session); + this.studio = new YTStudio(this.session); + this.kids = new YTKids(this.session); + this.actions = this.session.actions; + } + + static async create(config: InnertubeConfig = {}): Promise { + return new Innertube(await Session.create(config)); + } + + /** + * Retrieves video info. + * @param video_id - The video id. + * @param client - The client to use. + */ + async getInfo(video_id: string, client?: InnerTubeClient): Promise { + throwIfMissing({ video_id }); + + const cpn = generateRandomString(16); + + const initial_info = this.actions.getVideoInfo(video_id, cpn, client); + const continuation = this.actions.execute('/next', { videoId: video_id }); + + const response = await Promise.all([ initial_info, continuation ]); + return new VideoInfo(response, this.actions, this.session.player, cpn); + } + + /** + * Retrieves basic video info. + * @param video_id - The video id. + * @param client - The client to use. + */ + async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise { + throwIfMissing({ video_id }); + + const cpn = generateRandomString(16); + const response = await this.actions.getVideoInfo(video_id, cpn, client); + + return new VideoInfo([ response ], this.actions, this.session.player, cpn); + } + + /** + * Searches a given query. + * @param query - The search query. + * @param filters - Search filters. + */ + 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); + + return new Search(this.actions, response); + } + + /** + * Retrieves search suggestions for a given query. + * @param query - The search query. + */ + async getSearchSuggestions(query: string): Promise { + throwIfMissing({ query }); + + const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`); + url.searchParams.set('q', query); + url.searchParams.set('hl', this.session.context.client.hl); + url.searchParams.set('gl', this.session.context.client.gl); + url.searchParams.set('ds', 'yt'); + url.searchParams.set('client', 'youtube'); + url.searchParams.set('xssi', 't'); + url.searchParams.set('oe', 'UTF'); + + const response = await this.session.http.fetch(url); + const response_data = await response.text(); + + const data = JSON.parse(response_data.replace(')]}\'', '')); + const suggestions = data[1].map((suggestion: any) => suggestion[0]); + + return suggestions; + } + + /** + * Retrieves comments for a video. + * @param video_id - The video id. + * @param sort_by - Sorting options. + */ + 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 }); + + return new Comments(this.actions, response.data); + } + + /** + * Retrieves YouTube's home feed (aka recommendations). + */ + async getHomeFeed(): Promise { + const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' }); + return new HomeFeed(this.actions, response); + } + + /** + * Returns the account's library. + */ + async getLibrary(): Promise { + const response = await this.actions.execute('/browse', { browseId: 'FElibrary' }); + return new Library(this.actions, response); + } + + /** + * Retrieves watch history. + * Which can also be achieved with {@link getLibrary}. + */ + async getHistory(): Promise { + const response = await this.actions.execute('/browse', { browseId: 'FEhistory' }); + return new History(this.actions, response); + } + + /** + * Retrieves trending content. + */ + async getTrending(): Promise> { + const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true }); + return new TabbedFeed(this.actions, response); + } + + /** + * Retrieves subscriptions feed. + */ + async getSubscriptionsFeed(): Promise> { + const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions', parse: true }); + return new Feed(this.actions, response); + } + + /** + * Retrieves contents for a given channel. + * @param id - Channel id + */ + async getChannel(id: string): Promise { + throwIfMissing({ id }); + const response = await this.actions.execute('/browse', { browseId: id }); + return new Channel(this.actions, response); + } + + /** + * Retrieves notifications. + */ + async getNotifications(): Promise { + const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' }); + return new NotificationsMenu(this.actions, response); + } + + /** + * Retrieves unseen notifications count. + */ + async getUnseenNotificationsCount(): Promise { + const response = await this.actions.execute('/notification/get_unseen_count'); + // TODO: properly parse this + return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0; + } + + /** + * Retrieves playlist contents. + * @param id - Playlist id + */ + async getPlaylist(id: string): Promise { + throwIfMissing({ id }); + + if (!id.startsWith('VL')) { + id = `VL${id}`; + } + + const response = await this.actions.execute('/browse', { browseId: id }); + + return new Playlist(this.actions, response); + } + + /** + * Retrieves a given hashtag's page. + * @param hashtag - The hashtag to fetch. + */ + async getHashtag(hashtag: string): Promise { + throwIfMissing({ hashtag }); + + const params = Proto.encodeHashtag(hashtag); + const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params }); + + return new HashtagFeed(this.actions, response); + } + + /** + * An alternative to {@link download}. + * Returns deciphered streaming data. + * + * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}. + * @param video_id - The video id. + * @param options - Format options. + */ + async getStreamingData(video_id: string, options: FormatOptions = {}): Promise { + const info = await this.getBasicInfo(video_id); + return info.chooseFormat(options); + } + + /** + * Downloads a given video. If you only need the direct download link see {@link getStreamingData}. + * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}. + * @param video_id - The video id. + * @param options - Download options. + */ + async download(video_id: string, options?: DownloadOptions): Promise> { + const info = await this.getBasicInfo(video_id, options?.client); + return info.download(options); + } + + /** + * Resolves the given URL. + * @param url - The URL. + */ + async resolveURL(url: string): Promise { + const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true }); + return response.endpoint; + } + + /** + * Utility method to call an endpoint without having to use {@link Actions}. + * @param endpoint -The endpoint to call. + * @param args - Call arguments. + */ + call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise; + call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise; + call(endpoint: NavigationEndpoint, args?: object): Promise { + return endpoint.call(this.actions, args); + } +} + +export default Innertube; diff --git a/deno/src/core/AccountManager.ts b/deno/src/core/AccountManager.ts new file mode 100644 index 00000000..9be44183 --- /dev/null +++ b/deno/src/core/AccountManager.ts @@ -0,0 +1,110 @@ +import Proto from '../proto/index.ts'; +import type Actions from './Actions.ts'; +import type { ApiResponse } from './Actions.ts'; + +import Analytics from '../parser/youtube/Analytics.ts'; +import TimeWatched from '../parser/youtube/TimeWatched.ts'; +import AccountInfo from '../parser/youtube/AccountInfo.ts'; +import Settings from '../parser/youtube/Settings.ts'; + +import { InnertubeError } from '../utils/Utils.ts'; + +class AccountManager { + #actions: Actions; + + channel: { + editName: (new_name: string) => Promise; + editDescription: (new_description: string) => Promise; + getBasicAnalytics: () => Promise; + }; + + constructor(actions: Actions) { + this.#actions = actions; + + this.channel = { + /** + * Edits channel name. + * @param new_name - The new channel name. + */ + editName: (new_name: string) => { + 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' + }); + }, + /** + * Edits channel description. + * @param new_description - The new description. + */ + editDescription: (new_description: string) => { + 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' + }); + }, + /** + * Retrieves basic channel analytics. + */ + getBasicAnalytics: () => this.getAnalytics() + }; + } + + /** + * Retrieves channel info. + */ + async getInfo(): Promise { + 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' }); + return new AccountInfo(response); + } + + /** + * Retrieves time watched statistics. + */ + async getTimeWatched(): Promise { + const response = await this.#actions.execute('/browse', { + browseId: 'SPtime_watched', + client: 'ANDROID' + }); + + return new TimeWatched(response); + } + + /** + * Opens YouTube settings. + */ + async getSettings(): Promise { + const response = await this.#actions.execute('/browse', { + browseId: 'SPaccount_overview' + }); + + return new Settings(this.#actions, response); + } + + /** + * Retrieves basic channel analytics. + */ + 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 + }); + + return new Analytics(response); + } +} + +export default AccountManager; \ No newline at end of file diff --git a/deno/src/core/Actions.ts b/deno/src/core/Actions.ts new file mode 100644 index 00000000..83ec2e8c --- /dev/null +++ b/deno/src/core/Actions.ts @@ -0,0 +1,231 @@ +import Parser, { NavigateAction } from '../parser/index.ts'; +import { InnertubeError } from '../utils/Utils.ts'; + +import type Session from './Session.ts'; + +import type { + IBrowseResponse, IGetNotificationsMenuResponse, + INextResponse, IPlayerResponse, IResolveURLResponse, + ISearchResponse, IUpdatedMetadataResponse, + IParsedResponse, IRawResponse +} from '../parser/types/index.ts'; + +export interface ApiResponse { + success: boolean; + status_code: number; + data: IRawResponse; +} + +export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string; + +export type ParsedResponse = + T extends '/player' ? IPlayerResponse : + T extends '/search' ? ISearchResponse : + T extends '/browse' ? IBrowseResponse : + T extends '/next' ? INextResponse : + T extends '/updated_metadata' ? IUpdatedMetadataResponse : + T extends '/navigation/resolve_url' ? IResolveURLResponse : + T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse : + IParsedResponse; + +class Actions { + #session: Session; + + constructor(session: Session) { + this.#session = session; + } + + get session(): Session { + return this.#session; + } + + /** + * Mimmics the Axios API using Fetch's Response object. + * @param response - The response object. + */ + async #wrap(response: Response): Promise { + return { + success: response.ok, + status_code: response.status, + data: JSON.parse(await response.text()) + }; + } + + /** + * 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. + * @param client - The client to use. + * @param params - Call parameters. + */ + async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise { + const s_url = new URL(url); + + s_url.searchParams.set('ver', '2'); + s_url.searchParams.set('c', client.client_name.toLowerCase()); + s_url.searchParams.set('cbrver', client.client_version); + s_url.searchParams.set('cver', client.client_version); + + for (const key of Object.keys(params)) { + s_url.searchParams.set(key, params[key]); + } + + const response = await this.#session.http.fetch(s_url); + + return response; + } + + /** + * Executes an API call. + * @param endpoint - The endpoint to call. + * @param args - Call arguments + */ + async execute(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise>; + async execute(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise; + async execute(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise | ApiResponse> { + let data; + + if (args && !args.protobuf) { + data = { ...args }; + + if (Reflect.has(data, 'browseId')) { + if (this.#needsLogin(data.browseId) && !this.#session.logged_in) + throw new InnertubeError('You must be signed in to perform this operation.'); + } + + if (Reflect.has(data, 'override_endpoint')) + delete data.override_endpoint; + + if (Reflect.has(data, 'parse')) + delete data.parse; + + if (Reflect.has(data, 'request')) + delete data.request; + + if (Reflect.has(data, 'clientActions')) + delete data.clientActions; + + if (Reflect.has(data, 'settingItemIdForClient')) + delete data.settingItemIdForClient; + + if (Reflect.has(data, 'action')) { + data.actions = [ data.action ]; + delete data.action; + } + + if (Reflect.has(data, 'boolValue')) { + data.newValue = { boolValue: data.boolValue }; + delete data.boolValue; + } + + if (Reflect.has(data, 'token')) { + data.continuation = data.token; + delete data.token; + } + + if (data?.client === 'YTMUSIC') { + data.isAudioOnly = true; + } + } else if (args) { + data = args.serialized_data; + } + + const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint; + + const response = await this.#session.http.fetch(target_endpoint, { + method: 'POST', + body: args?.protobuf ? data : JSON.stringify((data || {})), + headers: { + 'Content-Type': args?.protobuf ? + 'application/x-protobuf' : + 'application/json' + } + }); + + if (args?.parse) { + let parsed_response = Parser.parseResponse>(await response.json()); + + // Handle redirects + if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.first()?.type === 'navigateAction') { + const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction); + if (navigate_action) { + parsed_response = await navigate_action.endpoint.call(this, { parse: true }); + } + } + + return parsed_response; + } + + return this.#wrap(response); + } + + #isBrowse(response: IParsedResponse): response is IBrowseResponse { + return 'on_response_received_actions' in response; + } + + #needsLogin(id: string) { + return [ + 'FElibrary', + 'FEhistory', + 'FEsubscriptions', + 'FEmusic_listening_review', + 'FEmusic_library_landing', + 'SPaccount_overview', + 'SPaccount_notifications', + 'SPaccount_privacy', + 'SPtime_watched' + ].includes(id); + } +} + +export default Actions; \ No newline at end of file diff --git a/deno/src/core/Feed.ts b/deno/src/core/Feed.ts new file mode 100644 index 00000000..c072b68b --- /dev/null +++ b/deno/src/core/Feed.ts @@ -0,0 +1,214 @@ +import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.ts'; +import Parser, { ReloadContinuationItemsCommand } from '../parser/index.ts'; +import { concatMemos, InnertubeError } from '../utils/Utils.ts'; +import type Actions from './Actions.ts'; + +import BackstagePost from '../parser/classes/BackstagePost.ts'; +import Channel from '../parser/classes/Channel.ts'; +import CompactVideo from '../parser/classes/CompactVideo.ts'; +import GridChannel from '../parser/classes/GridChannel.ts'; +import GridPlaylist from '../parser/classes/GridPlaylist.ts'; +import GridVideo from '../parser/classes/GridVideo.ts'; +import Playlist from '../parser/classes/Playlist.ts'; +import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo.ts'; +import PlaylistVideo from '../parser/classes/PlaylistVideo.ts'; +import Post from '../parser/classes/Post.ts'; +import ReelItem from '../parser/classes/ReelItem.ts'; +import ReelShelf from '../parser/classes/ReelShelf.ts'; +import RichShelf from '../parser/classes/RichShelf.ts'; +import Shelf from '../parser/classes/Shelf.ts'; +import Tab from '../parser/classes/Tab.ts'; +import Video from '../parser/classes/Video.ts'; + +import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction.ts'; +import ContinuationItem from '../parser/classes/ContinuationItem.ts'; +import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults.ts'; +import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults.ts'; +import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo.ts'; + +import type MusicQueue from '../parser/classes/MusicQueue.ts'; +import type RichGrid from '../parser/classes/RichGrid.ts'; +import type SectionList from '../parser/classes/SectionList.ts'; + +import type { IParsedResponse } from '../parser/types/index.ts'; +import type { ApiResponse } from './Actions.ts'; + +class Feed { + #page: T; + #continuation?: ObservedArray; + #actions: Actions; + #memo: Memo; + + constructor(actions: Actions, response: ApiResponse | IParsedResponse, already_parsed = false) { + if (this.#isParsed(response) || already_parsed) { + this.#page = response as T; + } else { + this.#page = Parser.parseResponse(response.data); + } + + const memo = concatMemos(...[ + this.#page.contents_memo, + this.#page.continuation_contents_memo, + this.#page.on_response_received_commands_memo, + this.#page.on_response_received_endpoints_memo, + this.#page.on_response_received_actions_memo, + this.#page.sidebar_memo, + this.#page.header_memo + ]); + + if (!memo) + throw new InnertubeError('No memo found in feed'); + + this.#memo = memo; + this.#actions = actions; + } + + #isParsed(response: IParsedResponse | ApiResponse): response is IParsedResponse { + return !('data' in response); + } + + /** + * Get all videos on a given page via memo + */ + static getVideosFromMemo(memo: Memo) { + return memo.getType