Compare commits

...

125 Commits

Author SHA1 Message Date
github-actions[bot]
0ad26f28d9 chore(main): release 3.1.1 (#330)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-01 16:30:10 -03:00
ChunkyProgrammer
4c7b8a3403 fix(Channel): getting community continuations (#329) 2023-03-01 16:28:26 -03:00
github-actions[bot]
33a6e740d7 chore(main): release 3.1.0 (#318)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-26 20:35:20 -03:00
LuanRT
0b1840a62c chore(docs): update examples to reflect recent changes [skip ci] 2023-02-26 20:28:16 -03:00
LuanRT
f4e0f30e6e fix: send correct UA for Android requests
Related: #322
2023-02-26 19:21:41 -03:00
LuanRT
200632f374 fix(parser): export YTNodes individually so they can be used as types
Related: #321
2023-02-26 18:56:04 -03:00
LuanRT
f933cb45bc feat(VideoSecondaryInfo): add support for attributed descriptions (#325) 2023-02-26 16:47:47 -03:00
absidue
a0e6cef00f fix(PlayerMicroformat): Make the embed field optional (#320) 2023-02-25 12:11:03 -03:00
absidue
a0bfe16427 feat: Add upcoming and live info to playlist videos (#317) 2023-02-20 18:25:53 -03:00
Daniel Wykerd
9d352b58eb docs: update imports for platforms (#315)
* docs: fix browser import

* docs: add deno.land instructions

As mentioned in issue #314
2023-02-17 14:53:06 -03:00
github-actions[bot]
6b6c80ddf1 chore(main): release 3.0.0 (#309)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-17 01:22:53 -03:00
LuanRT
58a6c84121 style: lint and format 2023-02-16 23:10:23 -03:00
LuanRT
63b1261b7c deps: bump Jinter to 0.4.1 2023-02-16 23:09:40 -03:00
dependabot[bot]
d2eff3bfb8 build(deps): bump undici from 5.14.0 to 5.19.1 (#313)
Bumps [undici](https://github.com/nodejs/undici) from 5.14.0 to 5.19.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.14.0...v5.19.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-16 21:49:49 -03:00
LuanRT
b668ba8cfb style(docs): rephrase some things 2023-02-16 19:38:48 -03:00
LuanRT
0b88575614 docs(browser): add ytjsexample.pages.dev live example 2023-02-16 19:32:24 -03:00
LuanRT
bed0ff4154 docs(readme): fix formatting 2023-02-16 19:05:42 -03:00
LuanRT
27a50a2a7e docs: add documentation for Feed, FilterableFeed and TabbedFeed 2023-02-16 18:38:10 -03:00
LuanRT
d4f2d704bb build: update package description 2023-02-16 17:03:17 -03:00
LuanRT
97f181b212 docs: update browser example 2023-02-16 07:50:02 -03:00
LuanRT
251ed74bba chore(ChannelAgeGate): fix node type
channelAgeGate ---> ChannelAgeGate
2023-02-16 07:15:52 -03:00
LuanRT
1cdf701c84 feat(parser): add ChannelAgeGate node 2023-02-16 07:07:34 -03:00
LuanRT
bf12740333 feat: add support for hashtag feeds (#312)
* feat: add hashtag params proto

* feat: add support for hashtags

* chore: add test

* docs: update API ref

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

* fix: do not throw when missing the header
2023-02-16 06:46:20 -03:00
LuanRT
0d77b59945 chore: make browser example more complete
See: https://ytjsexample.pages.dev/
2023-02-14 06:53:28 -03:00
LuanRT
6e30309f56 style: clean up and fix minor inconsistencies 2023-02-13 19:42:49 -03:00
ChunkyProgrammer
e37cf62732 fix: assign MetadataBadge's label (#311) 2023-02-13 03:15:06 -03:00
LuanRT
567fdbaf52 docs(parser): fix parser.ts link 2023-02-12 08:55:26 -03:00
LuanRT
0a22319d9e chore(docs): update test status badge 2023-02-12 08:47:36 -03:00
LuanRT
eb72c2f6ef refactor(parser): improve typings and do some refactoring (#305)
* dev: add response types

* dev: refactor `Parser#parseResponse()`

* dev: update YouTube parsers

* dev: update YouTube Music classes

* dev: update YouTube Kids classes

* dev: update core classes

* dev(Parser): fix some inconsistencies

* chore: update docs

* chore: update docs x2

* fix: export response types 

* chore(docs): update parser example
2023-02-12 07:04:17 -03:00
Daniel Wykerd
2ccbe2ce62 refactor!: cleanup platform support (#306)
* refactor!: cleanup platform support

* chore: lint

* fix: web platform

* feat: provide UniversalCache

Provide UniversalCache as a wrapper around Platform.shim.Cache.

* fix: invalid import

* refactor: remove isolated-vm support

* fix: type info

* refactor: cleanup exports

* fix: mark jintr as external dependency

In the bundled CJS node build, mark jintr as external.

* chore: add additional exports

web exports provide a way to select web implementation manually without
relying on the bundler to select it correctly from the "exports" field

web points to src/platform/web.js
web.bundle points to bundle/browser.js
web.bundle.browser points to bundle/browser.min.js

agnostic exports provide users of the library to provide their own
platform implementation without first importing the default one.

agnostic points to src/platform/lib.ts

* fix: toDash on web

* revert: eval is synchronous

* fix: use serializeDOM in FormatUtils

* ci: automate releases with `release-please`

* chore: clean up workflow files

* ci: fix NPM publish action

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2023-02-12 04:21:44 -03:00
absidue
a69e43bf3a feat(FormatUtils): support multiple audio tracks in the DASH manifest (#308) 2023-02-11 20:34:39 -03:00
absidue
b2900f48a7 feat(Channel): Add getters for all optional tabs (#303)
* feat(Channel): Add getters for all optional tabs

* Fix typo in test description

Co-authored-by: LuanRT <luan.lrt4@gmail.com>

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2023-02-02 00:29:54 -03:00
absidue
d612590530 fix(TopicChannelDetails): avatar and subtitle parsing (#302) 2023-02-01 17:17:31 -03:00
Daniel Wykerd
e82e23dfbb feat(parser): Text#toHTML (#300)
Added support to render Text nodes as HTML for use in web applications.
2023-02-01 16:27:59 -03:00
absidue
f62c66db39 fix(ChannelAboutFullMetadata): fix error when there are no primary links (#299) 2023-01-29 21:28:19 -03:00
ChunkyProgrammer
de61782f1a feat: add parser support for MultiImage community posts (#298) 2023-01-29 14:39:46 -03:00
absidue
ceefbed98c feat: allow checking whether a channel has optional tabs (#296) 2023-01-29 14:37:09 -03:00
LuanRT
315d89f84a refactor(Player): remove unneeded parameters 2023-01-29 02:26:18 -03:00
LuanRT
2ea3602b61 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2023-01-29 01:55:17 -03:00
LuanRT
b7df3d6df4 refactor: clean up backstage post nodes 2023-01-29 01:54:24 -03:00
ChunkyProgrammer
2acb7da019 feat: parse isLive in CompactVideo (#294)
* Feat: parse isLive in CompactVideo

* Use 3 equal signs

Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>

* use parse array for badges

add is_premiere, is_new, is_fundraiser

---------

Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2023-01-27 14:44:32 -03:00
LuanRT
0b991800a5 feat: extract channel error alert 2023-01-27 07:15:17 -03:00
LuanRT
50ef71284d feat(Channel): add support for sorting the playlist tab (#295) 2023-01-27 06:37:35 -03:00
LuanRT
d6c5a9b971 feat: improve support for dubbed content (#293)
* feat(Format): add `language`, `is_dubbed` and `is_original`

* feat: add a format filtering option to the DASH function
> And a simple language option to VideoInfo's download method.

* chore: update docs

* feat: improve audio track info parsing

* feat(Format): parse `audioTrack` prop
2023-01-27 00:42:20 -03:00
LuanRT
0fc29f0bbf feat(ytkids): add getChannel() (#292) 2023-01-23 05:38:53 -03:00
LuanRT
2bbefefbb7 feat: add support for YouTube Kids (#291)
* dev: add `WEB_KIDS` innertube client

* refactor: move DASH manifest stuff out of `VideoInfo`
This makes it easier to use these functions elsewhere.

* feat(ytkids): add `Kids#getInfo()` & `Kids#search()`

* feat: add `Innertube#kids.getHomeFeed()`

* docs: add YouTube Kids API ref

* docs: fix typo

* docs: fix yet another typo

* docs: update YouTube Music API ref
Unrelated but required to reflect changes made to the DASH manifest generation functions

* chore: lint

* chore: add tests

* feat: include `captions` in `VideoInfo`

* chore: fix tests
2023-01-23 03:39:51 -03:00
absidue
13ad3774c9 fix(VideoInfo): Gracefully handle missing watch next continuation (#288) 2023-01-23 03:36:38 -03:00
LuanRT
8051a7dee6 refactor: improve live chat polling and error handling (#287) 2023-01-21 02:56:10 -03:00
LuanRT
2842b1d917 chore(release): v2.9.0 2023-01-11 05:41:02 -03:00
LuanRT
870b2811d9 chore(Comments): reword a few things in the docs 2023-01-10 23:25:31 -03:00
LuanRT
1aedbd3ea6 refactor(ytmusic): minor improvements to Library 2023-01-10 23:24:12 -03:00
LuanRT
e8af2a603d fix(Playlist): trying to parse an already parsed response (#286)
This resulted in a 'InnertubeError: Type not found!' which was then followed by 'InnertubeError: This playlist does not exist' when retrieving the last page of a long playlist.
2023-01-10 17:18:16 -03:00
LuanRT
8e37efa575 refactor: improve livechat parser & add remaining action nodes (#285)
* refactor: improve live chat parsers & add missing nodes

* chore: update example and docs

* docs: rephrasing/formatting

* chore: remove unneeded test (unrelated)
2023-01-10 01:44:51 -03:00
absidue
5a362a0bd5 feat(EmojiRun): Add is_custom to identify custom emojis (#283) 2023-01-10 01:43:18 -03:00
absidue
89ee68b084 refactor(LiveChat): Only store required video info values (#281) 2023-01-09 16:45:02 -03:00
LuanRT
dca61c3a22 feat: finalize comment section nodes (#280)
* fix: comment translation proto missing channel id

* feat: finalize nodes

* docs: update API ref

* chore: update tests
2023-01-09 08:14:31 -03:00
LuanRT
56e6e23453 chore(release): v2.8.0 2023-01-06 03:18:17 -03:00
LuanRT
00fa514b03 feat: add support for generating sessions locally (#277)
* feat: add visitor data proto

* feat: add support for generating session data locally

* chore: add test
2023-01-06 03:06:49 -03:00
LuanRT
d36389c865 refactor(VideoInfo): simplify watch next feed extraction 2023-01-05 21:44:56 -03:00
LuanRT
55ca986888 chore: use optional chaining to avoid problems 2023-01-05 21:34:04 -03:00
LuanRT
b04df7e119 chore: lint 2023-01-05 21:22:50 -03:00
LuanRT
d8d92866d1 fix(Format): some types were incorrect 2023-01-05 20:56:55 -03:00
LuanRT
b4b0731589 refactor: remove unneeded check when generating search filter params
YouTube doesn't do this so I don't see why we should.
2023-01-05 20:32:14 -03:00
LuanRT
d69d701869 fix(VideoInfo): watch next feed not being parsed when logged out (#276) 2023-01-05 19:09:16 -03:00
absidue
cd4d28c951 feat: add live stream start_timestamp (#275) 2023-01-05 17:35:39 -03:00
absidue
22b9c174bb feat: add is_live and is_upcoming to VideoDetails (#271)
* feat: add is_live and is_upcoming to VideoDetails

* chore: add tests
2023-01-03 20:52:05 -03:00
LuanRT
b704c8e78c chore(release): v2.7.0 2023-01-02 00:00:13 -03:00
LuanRT
bbfeb99f55 chore: update docs 2023-01-01 23:10:38 -03:00
LuanRT
f2adeeeab4 docs: rephrasing 2023-01-01 23:04:04 -03:00
LuanRT
3756e63996 feat(Search): add support for features filter (#270) 2023-01-01 22:40:35 -03:00
LuanRT
a27807b6c1 feat: allow enabling safety mode (#269)
Unrelated: this also simplifies the creation of sessions without a player instance.
2023-01-01 19:55:08 -03:00
LuanRT
5cfb969e33 feat: implement Innertube#resolveURL(url) (#268) 2022-12-31 18:35:55 -03:00
LuanRT
1163125f5c feat: add LiveChatRestrictedParticipation node (#267) 2022-12-31 17:42:59 -03:00
LuanRT
9ac5043309 chore: clean up & remove unneeded code (#265) 2022-12-31 05:49:41 -03:00
LuanRT
6a4b4f3359 feat: add support for chapters & video heatmap (#263)
* feat: add support for chapters & video heatmap

* chore: add tests
2022-12-27 04:17:05 -03:00
LuanRT
2b3642ba63 feat: add support for searching within a channel (#262)
* feat(Channel): add support for searching

* dev: add channel search test

* chore: update docs
2022-12-26 18:56:37 -03:00
LuanRT
fb2e237284 fix: add YouTube Studio to the list of clients (#261)
As of December 16, YouTube Studio (Android) endpoints fail with a "Precondition check failed." message. If a newer version of the YouTube app is used then it throws a 404, indicating that it is now a requirement to use the correct client for YT Studio requests. I would say that's a bit of a bummer as we'll have to keep track of yet another client's version to make sure it doesn't get too outdated.
2022-12-20 18:34:50 -03:00
LuanRT
6f3deaf16a fix: use WEB client in setNotificationPreferences 2022-12-19 18:51:20 -03:00
LuanRT
d4382e81c3 chore: update proto and format code 2022-12-19 18:48:00 -03:00
LuanRT
89956cab46 chore: default Accept-Language to * 2022-12-19 18:46:47 -03:00
LuanRT
ac9341c769 chore(release): v2.6.0 2022-12-19 04:07:48 -03:00
LuanRT
cac762569a feat(Session): allow overriding geolocation (#260)
* Allow overriding geolocation

* Fix some inconsistencies (unrelated)
2022-12-19 03:55:38 -03:00
LuanRT
9978ebf085 refactor(Parser): reduce reliance on localised strings (#258) 2022-12-17 00:54:08 -03:00
LuanRT
b036e2fcdc feat(Channel): parse subscribe button
This way one can subscribe to a given channel simply by calling the button's endpoint.
2022-12-16 17:13:13 -03:00
LuanRT
e37f42f41b feat: bring back Video#is_live and add ExpandableMetadata (#256)
* bring back `Video#is_live`

* add ExpandableMetadata
2022-12-15 19:01:42 -03:00
absidue
883a023624 feat(TextRun): add support for formatting (#254) 2022-12-14 22:48:35 -03:00
LuanRT
506834b253 docs: fix formatting (oops) 2022-12-12 01:18:42 -03:00
LuanRT
87e7ef77eb chore(release): v2.5.2 2022-12-12 00:21:32 -03:00
LuanRT
27fdd8268a docs: update ToC 2022-12-12 00:14:55 -03:00
LuanRT
d4ea87b8b0 chore(docs): fix typo 2022-12-12 00:13:24 -03:00
LuanRT
ec87eea20d chore: update deps 2022-12-12 00:10:56 -03:00
LuanRT
e43ad202f4 chore: update examples 2022-12-12 00:10:33 -03:00
LuanRT
104c36b450 docs: reword a few things 2022-12-11 23:58:26 -03:00
absidue
f5d61d70f2 fix: author and thumbnails for autogenerated playlists (#251) 2022-12-07 20:34:53 -03:00
LuanRT
c76f5f478d 2.5.1 2022-11-30 19:11:40 -03:00
LuanRT
49d1432b5a chore: fix a few inconsistencies 2022-11-30 19:02:49 -03:00
LuanRT
be157ef016 fix: signature decipher extraction failing (#249) 2022-11-30 18:39:37 -03:00
LuanRT
9f703203b6 chore(docs): update readme 2022-11-29 05:49:15 -03:00
LuanRT
516eeeff45 refactor: improve Search parser (#247)
* refactor: improve Search parser

* chore: lint
2022-11-29 03:50:17 -03:00
LuanRT
6caa679df6 chore(release) v2.5.0 2022-11-25 01:36:50 -03:00
LuanRT
2a87f42b32 fix(Search): check if WatchCardHeroVideo is null before casting
Related #243
2022-11-25 01:25:02 -03:00
LuanRT
f7c1e0f249 fix(Music): search endpoint missing
Related: #242
2022-11-23 20:04:24 -03:00
LuanRT
fe4c5433cf feat: make Player instance optional (#240) 2022-11-16 03:17:59 -03:00
LuanRT
0e5e0c0fab feat(Channel): add support for filters (#237)
* feat: add support for filters

Also add `channel#getShorts()` and `channel#getLiveStreams()`

* docs: update API ref

* chore: add tests
2022-11-14 19:08:16 -03:00
LuanRT
f0fd6146c7 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-11-14 15:32:08 -03:00
LuanRT
43061970c6 fix: export Player & Session classes 2022-11-14 15:30:40 -03:00
LuanRT
746023d9bb chore(docs): fix typo' 2022-11-12 19:36:47 -03:00
LuanRT
3102479dd9 chore(release): v2.4.1
:]
2022-11-12 19:07:06 -03:00
LuanRT
c7a13c948c chore: remove unnecessary code 2022-11-12 19:02:40 -03:00
LuanRT
ec875ba321 chore(release): v2.4.0 2022-11-12 18:49:56 -03:00
LuanRT
db77bba802 fix(NotificationsCount): default to 0 2022-11-12 17:29:07 -03:00
LuanRT
5ea0a0ebf8 feat: add support for switching accounts (cookie based auth only) (#236)
* feat: add support for switching accounts

* style: lint
2022-11-12 16:26:02 -03:00
LuanRT
0130229236 fix(Actions): do not send undefined payloads 2022-11-12 15:38:29 -03:00
LuanRT
da517fe6d1 refactor: improve home feed parsing (#234)
* chore: update tests

* style: format code

* docs: update API ref
2022-11-12 01:31:11 -03:00
LuanRT
95ff1e6c5e refactor(Library): use memo to get target YTNodes 2022-11-11 19:00:12 -03:00
LuanRT
0f8adfd9b8 chore(parser): ignore AdSlot 2022-11-11 17:23:13 -03:00
LuanRT
b514765354 chore(docs): update examples 2022-11-11 17:05:24 -03:00
LuanRT
3cbcd71a3a feat: add support for topic/auto-generated channels and fix minor parsing errors (#233)
* dev: add support for topic channels

* dev(parser): do not try to parse empty nodes

* dev: add support for auto-generated game channels
2022-11-11 00:38:44 -03:00
Burhan Syed
4c00f15f55 fix: WatchCardHeroVideo accessibilityData parse error (#231)
* fix #230: WatchCardHeroVideo AccessibilityData Parser error

* add WatchCardHeroVideo test case
2022-11-10 19:18:08 -03:00
LuanRT
ea1d206b26 2.3.3 2022-11-06 03:38:47 -03:00
LuanRT
aa334aacbd refactor: clean up, fix & remove outdated code (#228)
* dev: refactor and remove redundant code

* docs(music): update `Library` API ref

* docs: update examples

* chore: update lock file
2022-11-06 03:32:16 -03:00
LuanRT
1eda93ee08 fix(session): visitorData and originalUrl 2022-10-21 14:42:34 -03:00
LuanRT
fe0ac0a961 chore(studio): fix a small typo 2022-10-19 17:11:50 -03:00
Daniel Wykerd
8740deb1f2 feat: custom parser error handler (#222)
As suggested in issue #218
2022-10-18 18:44:22 -03:00
mdashlw
d71b762df5 fix: don't remove "VL" from playlist id (#223) 2022-10-18 18:42:55 -03:00
545 changed files with 19721 additions and 9808 deletions

View File

@@ -5,4 +5,5 @@ cache/
src/proto/youtube.ts
coverage/
node_modules/
dist/
dist/
src/proto/generated/

View File

@@ -2,13 +2,7 @@ version: 1
labels:
- label: "breaking-change"
title: "^refactor!:.*"
- label: "enhancement"
title: "^feat:.*"
- label: "bug"
title: "^fix:.*"
- label: "github"
files:
- ".github/.*"

20
.github/release.yml vendored
View File

@@ -1,20 +0,0 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- octocat
categories:
- title: Breaking Changes
labels:
- Semver-Major
- breaking-change
- title: New Features
labels:
- Semver-Minor
- enhancement
- title: Bug Fixes
- bug
- title: Other Changes
labels:
- "*"

View File

@@ -5,9 +5,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: srvaroa/labeler@master
with:

View File

@@ -1,17 +1,18 @@
name: Lint
name: lint
on: [push, pull_request]
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
eslint:
name: Lint
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: npm install and lint
run: |
npm install
npm run lint
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm ci
- run: npm run lint

View File

@@ -1,26 +0,0 @@
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 16.x, 18.x ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run test

62
.github/workflows/release-please.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: release-please
on:
push:
branches:
- main
jobs:
release-please:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v3
id: release
with:
release-type: node
package-name: youtubei.js
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "16.x"
- name: Build for Deno
run: |
npm ci
npm run build:deno
if: ${{ steps.release.outputs.release_created }}
- name: Move Deno files
run: |
mkdir build
mv deno build/deno
cp deno.ts build/deno.ts
cp {LICENSE,README.md} build
if: ${{ steps.release.outputs.release_created }}
- name: Push to the Deno branch
uses: s0/git-publish-subdir-action@develop
env:
REPO: self
BRANCH: deno
FOLDER: ./build
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SKIP_EMPTY_COMMITS: true
MESSAGE: "chore: ${{ steps.release.outputs.tag_name }} release"
TAG: ${{ steps.release.outputs.tag_name }}-deno
if: ${{ steps.release.outputs.release_created }}
- name: Remove Deno folder
run: rm -rf build
if: ${{ steps.release.outputs.release_created }}
- uses: actions/setup-node@v3
with:
node-version: "16.x"
registry-url: "https://registry.npmjs.org"
if: ${{ steps.release.outputs.release_created }}
- name: Publish package to npmjs
run: |
npm ci
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: ${{ steps.release.outputs.release_created }}

View File

@@ -11,9 +11,7 @@ jobs:
- uses: actions/stale@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.'
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. Remove the stale label or comment or this will be closed in 2 days'
days-before-stale: 60
days-before-close: 4

18
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm ci
- run: npm run test

6
.gitignore vendored
View File

@@ -66,6 +66,12 @@ tmp/
dist/
bundle/*.js.*
bundle/*.js
bundle/*.cjs
bundle/*.cjs.*
deno/
# VSCode files
.vscode/
# MacOS
.DS_Store

59
CHANGELOG.md Normal file
View File

@@ -0,0 +1,59 @@
# Changelog
## [3.1.1](https://github.com/LuanRT/YouTube.js/compare/v3.1.0...v3.1.1) (2023-03-01)
### Bug Fixes
* **Channel:** getting community continuations ([#329](https://github.com/LuanRT/YouTube.js/issues/329)) ([4c7b8a3](https://github.com/LuanRT/YouTube.js/commit/4c7b8a34030effa26c4ea186d3e9509128aec31c))
## [3.1.0](https://github.com/LuanRT/YouTube.js/compare/v3.0.0...v3.1.0) (2023-02-26)
### Features
* Add upcoming and live info to playlist videos ([#317](https://github.com/LuanRT/YouTube.js/issues/317)) ([a0bfe16](https://github.com/LuanRT/YouTube.js/commit/a0bfe164279ec27b0c49c6b0c32222c1a92df5c3))
* **VideoSecondaryInfo:** add support for attributed descriptions ([#325](https://github.com/LuanRT/YouTube.js/issues/325)) ([f933cb4](https://github.com/LuanRT/YouTube.js/commit/f933cb45bcb92c07b3bc063d63869a51cbff4eb0))
### Bug Fixes
* **parser:** export YTNodes individually so they can be used as types ([200632f](https://github.com/LuanRT/YouTube.js/commit/200632f374d5e0e105b600d579a2665a6fb36e38)), closes [#321](https://github.com/LuanRT/YouTube.js/issues/321)
* **PlayerMicroformat:** Make the embed field optional ([#320](https://github.com/LuanRT/YouTube.js/issues/320)) ([a0e6cef](https://github.com/LuanRT/YouTube.js/commit/a0e6cef00fb9e3f52593cec22704f7ddc1f7553e))
* send correct UA for Android requests ([f4e0f30](https://github.com/LuanRT/YouTube.js/commit/f4e0f30e6e94b347b28d67d9a86284ea2d23ee15)), closes [#322](https://github.com/LuanRT/YouTube.js/issues/322)
## [3.0.0](https://github.com/LuanRT/YouTube.js/compare/v2.9.0...v3.0.0) (2023-02-17)
### ⚠ BREAKING CHANGES
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306))
### Features
* add parser support for MultiImage community posts ([#298](https://github.com/LuanRT/YouTube.js/issues/298)) ([de61782](https://github.com/LuanRT/YouTube.js/commit/de61782f1a673cbe66ae9b410341e39b7501ba84))
* add support for hashtag feeds ([#312](https://github.com/LuanRT/YouTube.js/issues/312)) ([bf12740](https://github.com/LuanRT/YouTube.js/commit/bf12740333a82c26fe84e7c702c2fbb8859814fc))
* add support for YouTube Kids ([#291](https://github.com/LuanRT/YouTube.js/issues/291)) ([2bbefef](https://github.com/LuanRT/YouTube.js/commit/2bbefefbb7cb061f3e7b686158b7568c32f0da5d))
* allow checking whether a channel has optional tabs ([#296](https://github.com/LuanRT/YouTube.js/issues/296)) ([ceefbed](https://github.com/LuanRT/YouTube.js/commit/ceefbed98c70bb936e2d2df58c02834842acfdfc))
* **Channel:** Add getters for all optional tabs ([#303](https://github.com/LuanRT/YouTube.js/issues/303)) ([b2900f4](https://github.com/LuanRT/YouTube.js/commit/b2900f48a7aa4c22635e1819ba9f636e81964f2c))
* **Channel:** add support for sorting the playlist tab ([#295](https://github.com/LuanRT/YouTube.js/issues/295)) ([50ef712](https://github.com/LuanRT/YouTube.js/commit/50ef71284db41e5f94bb511892651d22a1d363a0))
* extract channel error alert ([0b99180](https://github.com/LuanRT/YouTube.js/commit/0b991800a5c67f0e702251982b52eb8531f36f19))
* **FormatUtils:** support multiple audio tracks in the DASH manifest ([#308](https://github.com/LuanRT/YouTube.js/issues/308)) ([a69e43b](https://github.com/LuanRT/YouTube.js/commit/a69e43bf3ae02f2428c4aa86f647e3e5e0db5ba6))
* improve support for dubbed content ([#293](https://github.com/LuanRT/YouTube.js/issues/293)) ([d6c5a9b](https://github.com/LuanRT/YouTube.js/commit/d6c5a9b971444d0cd746aaf5310d3389793680ea))
* parse isLive in CompactVideo ([#294](https://github.com/LuanRT/YouTube.js/issues/294)) ([2acb7da](https://github.com/LuanRT/YouTube.js/commit/2acb7da0198bfeca6ff911cf95cf06a220fccaa5))
* **parser:** add `ChannelAgeGate` node ([1cdf701](https://github.com/LuanRT/YouTube.js/commit/1cdf701c8403db6b681a26ecb1df2daa51add454))
* **parser:** Text#toHTML ([#300](https://github.com/LuanRT/YouTube.js/issues/300)) ([e82e23d](https://github.com/LuanRT/YouTube.js/commit/e82e23dfbb24dff3ddf45754c7319d783990e254))
* **ytkids:** add `getChannel()` ([#292](https://github.com/LuanRT/YouTube.js/issues/292)) ([0fc29f0](https://github.com/LuanRT/YouTube.js/commit/0fc29f0bbf965215146a6ae192494c74e6cefcbb))
### Bug Fixes
* assign MetadataBadge's label ([#311](https://github.com/LuanRT/YouTube.js/issues/311)) ([e37cf62](https://github.com/LuanRT/YouTube.js/commit/e37cf627322f688fcef18d41345f77cbccd58829))
* **ChannelAboutFullMetadata:** fix error when there are no primary links ([#299](https://github.com/LuanRT/YouTube.js/issues/299)) ([f62c66d](https://github.com/LuanRT/YouTube.js/commit/f62c66db396ba7d2f93007414101112b49d8375f))
* **TopicChannelDetails:** avatar and subtitle parsing ([#302](https://github.com/LuanRT/YouTube.js/issues/302)) ([d612590](https://github.com/LuanRT/YouTube.js/commit/d612590530f5fe590fee969810b1dd44c37f0457))
* **VideoInfo:** Gracefully handle missing watch next continuation ([#288](https://github.com/LuanRT/YouTube.js/issues/288)) ([13ad377](https://github.com/LuanRT/YouTube.js/commit/13ad3774c9783ed2a9f286aeee88110bd43b3a73))
### Code Refactoring
* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306)) ([2ccbe2c](https://github.com/LuanRT/YouTube.js/commit/2ccbe2ce6260ace3bfac8b4b391e583fbcc4e286))

View File

@@ -3,16 +3,16 @@
Thank you for taking the time to contribute!
The following is a set of guidelines for contributing to YouTube.js.
___
* [Issues](#issues)
* [Create a new issue](#issue-1)
* [Solve an issue](#issue-2)
* [Make changes](#changes)
* [Commit your updates](#changes-1)
* [Create a PR](#changes-2)
* [Run tests](#test)
* [Lint your code](#lint)
* [Build](#build)
- [Contributing to YouTube.js](#contributing-to-youtubejs)
- [Issues](#issues)
- [Create a new issue](#create-a-new-issue)
- [Solve an issue](#solve-an-issue)
- [Make changes](#make-changes)
- [Commit your updates](#commit-your-updates)
- [Pull Request](#pull-request)
- [Test](#test)
- [Lint](#lint)
- [Build](#build)
## Issues
@@ -66,16 +66,26 @@ npm run lint:fix
#### Build
```bash
# Node
npm run build:node
# Browser
npm run build:browser
npm run build:browser:prod
# Build all
npm run build
# Protobuf
npm run build:proto
# Parser map
npm run build:parser-map
# Deno
npm run build:deno
# ES Module
npm run build:esm
# Node
npm run bundle:node
# Browser
npm run bundle:browser
npm run bundle:browser:prod
```

327
README.md
View File

@@ -1,5 +1,3 @@
<!-- Hi there, fellow coder :) -->
<!-- BADGE LINKS -->
[npm]: https://www.npmjs.com/package/youtubei.js
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
@@ -10,45 +8,27 @@
<!-- OTHER LINKS -->
[project]: https://github.com/LuanRT/YouTube.js
[twitter]: https://twitter.com/lrt_nooneknows
[twitter]: https://twitter.com/thesciencephile
[discord]: https://discord.gg/syDu7Yks54
[nodejs]: https://nodejs.org
<!-- INTRODUCTION -->
<h1 align=center>
YouTube.js
</h1>
<h1 align=center>YouTube.js</h1>
<p align=center>
<i>
A full-featured wrapper around the InnerTube API, which is what YouTube itself uses.
</i>
</p>
<p align=center>A full-featured wrapper around the InnerTube API</p>
<p align="center">
<a href="https://github.com/LuanRT/YouTube.js/issues">
Report Bug
</a>
·
<a href="https://github.com/LuanRT/YouTube.js/issues">
Request Feature
</a>
</p>
<!-- BADGES -->
<div align="center">
[![Tests](https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml/badge.svg)][actions]
[![Latest version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![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]
<br>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][github-sponsors]
</div>
<!-- SPONSORS -->
<p align="center">
<a><sub>Special thanks to:<sub></a>
</p>
@@ -57,7 +37,7 @@
<body>
<tr>
<td align="center">
<a href="https://serpapi.com/">
<a href="https://serpapi.com/" target="_blank">
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
<br>
<b>
@@ -73,7 +53,6 @@
___
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
@@ -95,23 +74,20 @@ ___
<li><a href="#api">API</a></li>
</ul>
</li>
<li><a href="#implementing-custom-functionality">Implementing custom functionality </a></li>
<li><a href="#extending-the-library">Extending the library</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#contributors">Contributors</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#disclaimer">Disclaimer</a></li>
<li><a href="#license">License</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT -->
## About
## Description
InnerTube is an API used across all YouTube clients, it was created to simplify[^1] the internal structure of the platform in a way that updates, tweaks, and experiments can be easily made. This library handles all the low-level communication with InnerTube, providing a simple, fast, and efficient way to interact with YouTube programmatically.
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 contact us on our chat server [here](https://discord.gg/syDu7Yks54).
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 -->
## Getting Started
### Prerequisites
@@ -136,9 +112,11 @@ yarn add youtubei.js@latest
npm install github:LuanRT/YouTube.js
```
**TODO:** Deno install instructions (esm.sh possibly?)
When using Deno, you can import YouTube.js directly from deno.land:
```ts
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
```
<!-- USAGE -->
## Usage
Create an InnerTube instance:
```ts
@@ -153,8 +131,13 @@ To use YouTube.js in the browser you must proxy requests through your own server
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';
// We provide multiple exports for the web.
// Unbundled ESM version
import { Innertube } from 'youtubei.js/web';
// Bundled ESM version
// import { Innertube } from 'youtubei.js/web.bundle';
// Production Bundled ESM version
// import { Innertube } from 'youtubei.js/web.bundle.min';
await Innertube.create({
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
// Modify the request
@@ -172,7 +155,7 @@ YouTube.js supports streaming of videos in the browser by converting YouTube's s
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 { Innertube } from 'youtubei.js/web';
import dashjs from 'dashjs';
const youtube = await Innertube.create({ /* setup - see above */ });
@@ -197,7 +180,8 @@ const player = dashjs.MediaPlayer().create();
player.initialize(videoElement, uri, true);
```
Our browser example in [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/blob/main/examples/browser/web) provides a fully working example.
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/).
<a name="custom-fetch"></a>
## Providing your own fetch implementation
@@ -226,7 +210,7 @@ Our cache uses the `node:fs` module in Node-like environments, `Deno.writeFile`
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()
cache: new UniversalCache(false)
});
// You may wish to make the cache persistent (on Node and Deno)
@@ -245,7 +229,7 @@ const yt = await Innertube.create({
* `Innertube`
<details>
<summary>objects</summary>
<summary>Objects</summary>
<p>
* [.session](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/session.md)
@@ -254,13 +238,14 @@ const yt = await Innertube.create({
* [.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)
</p>
</details>
<details>
<summary>methods</summary>
<summary>Methods</summary>
<p>
* [.getInfo(video_id, client?)](#getinfo)
@@ -277,8 +262,10 @@ const yt = await Innertube.create({
* [.getNotifications()](#getnotifications)
* [.getUnseenNotificationsCount()](#getunseennotificationscount)
* [.getPlaylist(id)](#getplaylist)
* [.getHashtag(hashtag)](#gethashtag)
* [.getStreamingData(video_id, options)](#getstreamingdata)
* [.download(video_id, options?)](#download)
* [.resolveURL(url)](#resolveurl)
* [.call(endpoint, args?)](#call)
</p>
@@ -289,7 +276,7 @@ const yt = await Innertube.create({
Retrieves video info, including playback data and even layout elements such as menus, buttons, etc — all nicely parsed.
**Returns**: `Promise.<VideoInfo>`
**Returns**: `Promise<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
@@ -306,7 +293,7 @@ Retrieves video info, including playback data and even layout elements such as m
- `<info>#dislike()`
- Dislikes the video.
- `<info>#removeLike()`
- `<info>#removeRating()`
- Removes like/dislike.
- `<info>#getLiveChat()`
@@ -315,7 +302,7 @@ Retrieves video info, including playback data and even layout elements such as m
- `<info>#chooseFormat(options)`
- Used to choose streaming data formats.
- `<info>#toDash(url_transformer)`
- `<info>#toDash(url_transformer?, format_filter?)`
- Converts streaming data to an MPEG-DASH manifest.
- `<info>#download(options)`
@@ -344,7 +331,7 @@ Retrieves video info, including playback data and even layout elements such as m
Suitable for cases where you only need basic video metadata. Also, it is faster than [`getInfo()`](#getinfo).
**Returns**: `Promise.<VideoInfo>`
**Returns**: `Promise<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
@@ -356,7 +343,10 @@ Suitable for cases where you only need basic video metadata. Also, it is faster
Searches the given query on YouTube.
**Returns**: `Promise.<Search>`
**Returns**: `Promise<Search>`
> **Note**
> `Search` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
@@ -383,7 +373,7 @@ Searches the given query on YouTube.
### getSearchSuggestions(query)
Retrieves search suggestions for given query.
**Returns**: `Promise.<string[]>`
**Returns**: `Promise<string[]>`
| Param | Type | Description |
| --- | --- | --- |
@@ -393,7 +383,7 @@ Retrieves search suggestions for given query.
### getComments(video_id, sort_by?)
Retrieves comments for given video.
**Returns**: `Promise.<Comments>`
**Returns**: `Promise<Comments>`
| Param | Type | Description |
| --- | --- | --- |
@@ -406,13 +396,44 @@ See [`./examples/comments`](https://github.com/LuanRT/YouTube.js/blob/main/examp
### getHomeFeed()
Retrieves YouTube's home feed.
**Returns**: `Promise.<FilterableFeed>`
**Returns**: `Promise<HomeFeed>`
> **Note**
> `HomeFeed` extends the [`FilterableFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md) class.
<details>
<summary>Methods & Getters</summary>
<p>
- `<home_feed>#videos`
- Returns all videos in the home feed.
- `<home_feed>#posts`
- Returns all posts in the home feed.
- `<home_feed>#shelves`
- Returns all shelves in the home feed.
- `<home_feed>#filters`
- Returns available filters.
- `<home_feed>#applyFilter(name | ChipCloudChip)`
- Applies given filter and returns a new HomeFeed instance.
- `<home_feed>#getContinuation()`
- Retrieves feed continuation.
</p>
</details>
<a name="getlibrary"></a>
### getLibrary()
Retrieves the account's library.
**Returns**: `Promise.<Library>`
**Returns**: `Promise<Library>`
> **Note**
> `Library` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
<details>
<summary>Methods & Getters</summary>
@@ -421,10 +442,8 @@ Retrieves the account's library.
- `<library>#history`
- `<library>#watch_later`
- `<library>#liked_videos`
- `<library>#playlists`
- `<library>#playlists_section`
- `<library>#clips`
- `<library>#page`
- Returns original InnerTube response (sanitized).
</p>
</details>
@@ -433,7 +452,10 @@ Retrieves the account's library.
### getHistory()
Retrieves watch history.
**Returns**: `Promise.<History>`
**Returns**: `Promise<History>`
> **Note**
> `History` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
<details>
<summary>Methods & Getters</summary>
@@ -449,19 +471,22 @@ Retrieves watch history.
### getTrending()
Retrieves trending content.
**Returns**: `Promise.<TabbedFeed>`
**Returns**: `Promise<TabbedFeed<IBrowseResponse>>`
<a name="getsubscriptionsfeed"></a>
### getSubscriptionsFeed()
Retrieves subscriptions feed.
Retrieves the subscriptions feed.
**Returns**: `Promise.<Feed>`
**Returns**: `Promise<Feed<IBrowseResponse>>`
<a name="getchannel"></a>
### getChannel(id)
Retrieves contents for a given channel.
**Returns**: `Promise.<Channel>`
**Returns**: `Promise<Channel>`
> **Note**
> `Channel` extends the [`TabbedFeed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/tabbed-feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
@@ -472,11 +497,22 @@ Retrieves contents for a given channel.
<p>
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`
- `<channel>#getChannels()`
- `<channel>#getAbout()`
- `<channel>#search(query)`
- `<channel>#applyFilter(filter)`
- `<channel>#applyContentTypeFilter(content_type_filter)`
- `<channel>#applySort(sort)`
- `<channel>#getContinuation()`
- `<channel>#filters`
- `<channel>#content_type_filters`
- `<channel>#sort_filters`
- `<channel>#page`
</p>
</details>
@@ -487,7 +523,7 @@ See [`./examples/channel`](https://github.com/LuanRT/YouTube.js/blob/main/exampl
### getNotifications()
Retrieves notifications.
**Returns**: `Promise.<NotificationsMenu>`
**Returns**: `Promise<NotificationsMenu>`
<details>
<summary>Methods & Getter</summary>
@@ -503,13 +539,16 @@ Retrieves notifications.
### getUnseenNotificationsCount()
Retrieves unseen notifications count.
**Returns**: `Promise.<number>`
**Returns**: `Promise<number>`
<a name="getplaylist"></a>
### getPlaylist(id)
Retrieves playlist contents.
**Returns**: `Promise.<Playlist>`
**Returns**: `Promise<Playlist>`
> **Note**
> `Playlist` extends the [`Feed`](https://github.com/LuanRT/YouTube.js/blob/main/docs/API/feed.md) class.
| Param | Type | Description |
| --- | --- | --- |
@@ -525,19 +564,45 @@ Retrieves playlist contents.
</p>
</details>
<a name="gethashtag"></a>
### getHashtag(hashtag)
Retrieves a given hashtag's page.
**Returns**: `Promise<HashtagFeed>`
> **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 |
<details>
<summary>Methods & Getter</summary>
<p>
- `<hashtag>#applyFilter(filter)`
- Applies given filter and returns a new `HashtagFeed` instance.
- `<hashtag>#getContinuation()`
- Retrieves next batch of contents.
</p>
</details>
<a name="getstreamingdata"></a>
### getStreamingData(video_id, options)
Returns deciphered streaming data.
**Note:**
It is recommended to retrieve streaming data from a `VideoInfo`/`TrackInfo` object instead if you want to select formats manually, example:
> **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.<object>`
**Returns**: `Promise<object>`
| Param | Type | Description |
| --- | --- | --- |
@@ -548,7 +613,7 @@ console.info('Playback url:', url);
### download(video_id, options?)
Downloads a given video.
**Returns**: `Promise.<ReadableStream<Uint8Array>>`
**Returns**: `Promise<ReadableStream<Uint8Array>>`
| Param | Type | Description |
| --- | --- | --- |
@@ -557,76 +622,86 @@ Downloads a given video.
See [`./examples/download`](https://github.com/LuanRT/YouTube.js/blob/main/examples/download) for examples.
<a name="resolveurl"></a>
### resolveURL(url)
Resolves a given url.
**Returns**: `Promise<NavigationEndpoint>`
| Param | Type | Description |
| --- | --- | --- |
| url | `string` | Url to resolve |
<a name="call"></a>
### call(endpoint, args?)
Utility to call navigation endpoints.
**Returns**: `Promise.<ActionsResponse | ParsedResponse>`
**Returns**: `Promise<T extends IParsedResponse | IParsedResponse | ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| endpoint | `NavigationEndpoint` | The target endpoint |
| args? | `object` | Additional payload arguments |
## Extending the library
## Implementing custom functionality
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.
Something cool about YouTube.js is that it is completely modular and easy to tinker with. 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, you may want to call an endpoint directly, that can be achieved with the `Actions` class:
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';
const payload = {
videoId: 'jLTOuvBTLxA',
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.
};
(async () => {
const yt = await Innertube.create();
const response = await yt.actions.execute('/player', payload);
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.
});
console.info(response);
return videoInfo;
}
const videoInfo = await getVideoInfo('jLTOuvBTLxA');
console.info(videoInfo);
})();
```
Or maybe there's an interesting `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
Or perhaps there's a `NavigationEndpoint` in a parsed response and we want to call it to see what happens:
```ts
// ...
const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
const albums = artist.sections[1].as(MusicCarouselShelf);
import { Innertube, YTNodes } from 'youtubei.js';
// Say we have a button and want to “click” it
const button = albums.as(MusicCarouselShelf).header?.more_content;
if (button) {
// To do that, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
console.info(page);
}
(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
If you're working on an extension for the library or just want to have nicely typed and sanitized InnerTube responses for a project then have a look at our powerful parser!
<details>
<summary>Example:</summary>
<p>
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 } from 'youtubei.js';
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';
// Artist page response from YouTube Music
@@ -634,15 +709,18 @@ const data = readFileSync('./artist.json').toString();
const page = Parser.parseResponse(JSON.parse(data));
const header = page.header.item().as(MusicImmersiveHeader, MusicVisualHeader);
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(SingleColumnBrowseResults).tabs.get({ selected: false });
/**
* 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');
@@ -650,31 +728,26 @@ if (!tab)
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);
```
</p>
</details>
Detailed documentation can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
Documentation for the parser can be found [here](https://github.com/LuanRT/YouTube.js/blob/main/src/parser).
<!-- CONTRIBUTING -->
## 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.
<!-- CONTRIBUTORS -->
## Contributors
Thank you to all the wonderful people who have contributed to this project:
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors">
<img src="https://contrib.rocks/image?repo=LuanRT/YouTube.js" />
</a>
<!-- CONTACT -->
## Contact
LuanRT - [@lrt_nooneknows][twitter] - luan.lrt4@gmail.com
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
Project Link: [https://github.com/LuanRT/YouTube.js][project]
@@ -684,13 +757,11 @@ All trademarks, logos, and brand names are the property of their respective owne
Should you have any questions or concerns please contact me directly via email.
<!-- Footnotes -->
[^1]: https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491
<!-- LICENSE -->
## License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
<p align=" right">
(<a href="#top">back to top</a>)
</p>
</p>

View File

@@ -1,9 +0,0 @@
// Deno and browser runtimes
import Innertube from './src/Innertube';
export * from './src/utils';
export { YTNodes } from './src/parser/map';
export { default as Parser } from './src/parser';
export { default as Innertube } from './src/Innertube';
export default Innertube;

2
bundle/browser.d.ts vendored
View File

@@ -1 +1 @@
export * from '../dist/browser';
export * from '../dist/src/platform/lib.js';

1
bundle/node.d.cts Normal file
View File

@@ -0,0 +1 @@
export * from '../dist/src/platform/lib.js';

3
deno.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './deno/src/platform/deno.ts';
import Innertube from './deno/src/platform/deno.ts';
export default Innertube;

View File

@@ -46,7 +46,7 @@ Retrieves account information.
<p>
- `<accountinfo>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -63,7 +63,7 @@ Retrieves time watched statistics.
<p>
- `<timewatched>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -91,6 +91,9 @@ Retrieves YouTube settings.
- `<settings>#sidebar_items`
- Returns options available in the sidebar menu.
- `<settings>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -106,7 +109,7 @@ Retrieves basic channel analytics.
<p>
- `<analytics>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>

115
docs/API/feed.md Normal file
View File

@@ -0,0 +1,115 @@
# Feed
Represents a YouTube feed. This class provides a set of utility methods for parsing and interacting with feeds.
## API
* Feed
* [.videos](#videos)
* [.posts](#posts)
* [.channels](#channels)
* [.playlists](#playlists)
* [.shelves](#shelves)
* [.memo](#memo)
* [.page_contents](#page_contents)
* [.secondary_contents](#secondary_contents)
* [.page](#page)
* [.has_continuation](#has_continuation)
* [.getContinuationData()](#getcontinuationdata)
* [.getContinuation()](#getcontinuation)
* [.getShelf(title)](#getshelf)
<a name="videos"></a>
### videos
Returns all videos in the feed.
**Returns:** `ObservedArray<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>`
<a name="posts"></a>
### posts
Returns all posts in the feed.
**Returns:** `ObservedArray<Post | BackstagePost>`
<a name="channels"></a>
### channels
Returns all channels in the feed.
**Returns:** `ObservedArray<Channel | GridChannel>`
<a name="playlists"></a>
### playlists
Returns all playlists in the feed.
**Returns:** `ObservedArray<Playlist | GridPlaylist>`
<a name="shelves"></a>
### shelves
Returns all shelves in the feed.
**Returns:** `ObservedArray<Shelf | RichShelf | ReelShelf>`
<a name="memo"></a>
### memo
Returns the memoized feed contents.
**Returns:** `Memo`
<a name="page_contents"></a>
### page_contents
Returns the page contents.
**Returns:** `SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand`
<a name="secondary_contents"></a>
### secondary_contents
Returns the secondary contents node.
**Returns:** `SuperParsedResult<YTNode> | undefined `
<a name="page"></a>
### page
Returns the original InnerTube response, parsed and sanitized.
**Returns:** `T extends IParsedResponse = IParsedResponse`
<a name="has_continuation"></a>
### has_continuation
Returns whether the feed has a continuation.
**Returns:** `boolean`
<a name="getcontinuationdata"></a>
### getContinuationData()
Returns the continuation data.
**Returns:** `Promise<T | undefined>`
<a name="getcontinuation"></a>
### getContinuation()
Retrieves the feed's continuation.
**Returns:** `Promise<Feed<T>>`
<a name="getshelf"></a>
### getShelf(title)
Gets a shelf by its title.
**Returns:** `Shelf | RichShelf | ReelShelf | undefined`
| Param | Type | Description |
| --- | --- | --- |
| title | `string` | The title of the shelf to get |

View File

@@ -0,0 +1,38 @@
# FilterableFeed
Represents a feed that can be filtered.
> **Note**
> This class extends the [Feed](feed.md) class.
## API
* FilterableFeed
* [.filter_chips](#filter_chips)
* [.filters](#filters)
* [.getFilteredFeed(filter: string | ChipCloudChip)](#getfilteredfeed)
<a name="filter_chips"></a>
### filter_chips
Returns the feed's filter chips.
**Returns:** `ObservedArray<ChipCloudChip>`
<a name="filters"></a>
### filters
Returns the feed's filter chips as an array of strings.
**Returns:** `string[]`
<a name="getfilteredfeed"></a>
### getFilteredFeed(filter: string | ChipCloudChip)
Returns a new [Feed](feed.md) with the given filter applied.
**Returns:** `Promise<Feed<T>>`
| Param | Type | Description |
| --- | --- | --- |
| filter | `string` \| `ChipCloudChip` | The filter to apply |

View File

@@ -7,7 +7,7 @@ Handles direct interactions.
* InteractionManager
* [.like(video_id)](#like)
* [.dislike(video_id)](#dislike)
* [.removeLike(video_id)](#removelike)
* [.removeRating(video_id)](#removerating)
* [.subscribe(video_id)](#subscribe)
* [.unsubscribe(video_id)](#unsubscribe)
* [.comment(video_id, text)](#comment)
@@ -19,7 +19,7 @@ Handles direct interactions.
Likes given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -30,18 +30,18 @@ Likes given video.
Dislikes given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="removelike"></a>
<a name="removerating"></a>
### removeLike(video_id)
Remover like/dislike.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -52,7 +52,7 @@ Remover like/dislike.
Subscribes to given channel.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -63,7 +63,7 @@ Subscribes to given channel.
Unsubscribes from given channel.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -74,7 +74,7 @@ Unsubscribes from given channel.
Posts a comment on given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -86,7 +86,7 @@ Posts a comment on given video.
Translates given text using YouTube's comment translation feature.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -100,7 +100,7 @@ Translates given text using YouTube's comment translation feature.
Changes notification preferences for a given channel.
Only works with channels you are subscribed to.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

113
docs/API/kids.md Normal file
View File

@@ -0,0 +1,113 @@
# YouTube Kids
YouTube Kids is a modified version of the YouTube app, with a simplified interface and curated content. This class allows you to interact with its API.
## API
* Kids
* [.search(query)](#search)
* [.getInfo(video_id)](#getinfo)
* [.getChannel(channel_id)](#getchannel)
* [.getHomeFeed()](#gethomefeed)
<a name="search"></a>
### search(query)
Searches the given query on YouTube Kids.
**Returns:** `Promise.<Search>`
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | The query to search |
<details>
<summary>Methods & Getters</summary>
<p>
- `<search>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getinfo"></a>
### getInfo(video_id)
Retrieves video info.
**Returns:** `Promise.<VideoInfo>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The video id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<info>#toDash(url_transformer?, format_filter?)`
- Generates a DASH manifest from the streaming data.
- `<info>#chooseFormat(options)`
- Selects the format that best matches the given options. This method is used internally by `#download`.
- `<info>#download(options?)`
- Downloads the video.
- `<info>#addToWatchHistory()`
- Adds the video to the watch history.
- `<info>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getchannel"></a>
### getChannel(channel_id)
Retrieves channel info.
**Returns:** `Promise.<Channel>`
| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | The channel id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<channel>#getContinuation()`
- Retrieves next batch of videos.
- `<channel>#has_continuation`
- Returns whether there are more videos to retrieve.
- `<channel>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="gethomefeed"></a>
### getHomeFeed()
Retrieves the home feed.
**Returns:** `Promise.<HomeFeed>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<feed>#selectCategoryTab(tab: string | KidsCategoryTab)`
- Selects the given category tab.
- `<feed>#categories`
- Returns available categories.
- `<feed>#page`
- Returns the original InnerTube response(s), parsed and sanitized.

View File

@@ -1,6 +1,6 @@
# Music
# YouTube Music
YouTube Music class.
YouTube Music is a music streaming service developed by YouTube, a subsidiary of Google. It provides a tailored interface for the service oriented towards music streaming, with a greater emphasis on browsing and discovery compared to its main service. This class allows you to interact with its API.
## API
@@ -49,6 +49,21 @@ Retrieves track info.
- `<info>#available_tabs`
- Returns available tabs.
- `<info>#toDash(url_transformer?, format_filter?)`
- Generates a DASH manifest from the streaming data.
- `<info>#chooseFormat(options)`
- Selects the format that best matches the given options. This method is used internally by `#download`.
- `<info>#download(options?)`
- Downloads the track.
- `<info>#addToWatchHistory()`
- Adds the song to the watch history.
- `<info>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -99,7 +114,7 @@ Searches on YouTube Music.
- Returns songs shelf.
- `<search>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -124,6 +139,9 @@ Retrieves home feed.
- `<homefeed>#page`
- Returns original InnerTube response (sanitized).
- `<homefeed>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -139,7 +157,7 @@ Retrieves “Explore” feed.
<p>
- `<explore>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -155,20 +173,26 @@ Retrieves library.
<summary>Methods & Getters</summary>
<p>
- `<library>#getPlaylists(args?)`
- Retrieves the library's playlists.
- `<library>#applyFilter(filter)`
- Applies given filter to the library.
- `<library>#getAlbums(args?)`
- Retrieves the library's albums.
- `<library>#applySort(sort_by)`
- Applies given sort option to the library items.
- `<library>#getArtists(args?)`
- Retrieves the library's artists.
- `<library>#getContinuation()`
- Retrieves continuation of the library items.
- `<library>#getSongs(args?)`
- Retrieves the library's songs.
- `<library>#has_continuation`
- Checks if continuation is available.
- `<library>#getRecentActivity(args)`
- Retrieves recent activity.
- `<library>#filters`
- Returns available filters.
- `<library>#sort_options`
- Returns available sort options.
- `<library>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -189,7 +213,7 @@ Retrieves artist's info & content.
<p>
- `<artist>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -210,7 +234,7 @@ Retrieves given album.
<p>
- `<album>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -243,7 +267,7 @@ Retrieves given playlist.
- Checks if continuation is available.
- `<playlist>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
@@ -297,7 +321,7 @@ Retrieves your YouTube Music recap.
- Retrieves recap playlist.
- `<recap>#page`
- Returns original InnerTube response (sanitized).
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>

View File

@@ -16,7 +16,7 @@ Playlist management class.
Creates a playlist.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -28,7 +28,7 @@ Creates a playlist.
Deletes given playlist.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

View File

@@ -14,7 +14,7 @@ YouTube Studio class (WIP).
Uploads a custom thumbnail and sets it for a video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -26,7 +26,7 @@ Uploads a custom thumbnail and sets it for a video.
Updates given video's metadata.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -38,7 +38,7 @@ Updates given video's metadata.
Uploads a video to YouTube.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

62
docs/API/tabbed-feed.md Normal file
View File

@@ -0,0 +1,62 @@
# TabbedFeed
Represents a feed with tabs.
> **Note**
> This class extends the [Feed](feed.md) class.
## API
* TabbedFeed
* [.tabs](#tabs)
* [.getTabByName(title: string)](#gettabbyname)
* [.getTabByURL(url: string)](#gettabbyurl)
* [.hasTabWithURL(url: string)](#hastabwithurl)
* [.title](#title)
<a name="tabs"></a>
### tabs
Returns the feed's tabs as an array of strings.
**Returns:** `string[]`
<a name="gettabbyname"></a>
### getTabByName(title: string)
Fetches a tab by its title.
**Returns:** `Promise<TabbedFeed<T>>`
| Param | Type | Description |
| --- | --- | --- |
| title | `string` | The title of the tab to get |
<a name="gettabbyurl"></a>
### getTabByURL(url: string)
Fetches a tab by its URL.
**Returns:** `Promise<TabbedFeed<T>>`
| Param | Type | Description |
| --- | --- | --- |
| url | `string` | The URL of the tab to get |
<a name="hastabwithurl"></a>
### hasTabWithURL(url: string)
Returns whether the feed has a tab with the given URL.
**Returns:** `boolean`
| Param | Type | Description |
| --- | --- | --- |
| url | `string` | The URL to check |
<a name="title"></a>
### title
Returns the currently selected tab's title.
**Returns:** `string | undefined`

View File

@@ -2,7 +2,7 @@
YouTube is constantly changing, so it is not rare to see YouTube crawlers/scrapers breaking every now and then.
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning like this in the console:
Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (a.k.a YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g; YouTube adds a new feature / minor UI changes) the library will print a warning similar to this:
```
InnertubeError: SomeRenderer not found!
This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues!
@@ -17,7 +17,7 @@ This is a bug, want to help us fix it? Follow the instructions at https://github
}
```
This warning, however, **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.
## Adding a new renderer parser

View File

@@ -3,7 +3,7 @@ const { Innertube, UniversalCache } = require('youtubei.js');
(async () => {
const yt = await Innertube.create({
// required if you wish to use OAuth#cacheCredentials
cache: new UniversalCache()
cache: new UniversalCache(false)
});
// 'auth-pending' is fired with the info needed to sign in via OAuth.

View File

@@ -2,14 +2,14 @@
YouTube.js works in the browser!
## How to use
## 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`.
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
```ts
import { Innertube } from "youtubei.js/build/browser";
import { Innertube } from "youtubei.js/web.bundle.min";
const yt = await Innertube.create({
fetch: async (input, init) => {
@@ -54,8 +54,10 @@ const yt = await Innertube.create({
});
```
after that you can use the library as normal.
After that, you can use the library as normal.
## Example
We've got a full example in `examples/browser/web` using vite.
If you don't want to run the example yourself, you can see it in action here: [ytjsexample.pages.dev](https://ytjsexample.pages.dev/).

View File

@@ -1,20 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
</head>
<body>
<form>
<input type="text" name="id" placeholder="Video ID" />
<input type="submit" value="Play" />
</form>
<span id="video_name">
Library is loading...
</span>
<video></video>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube.js Example</title>
</head>
<body>
<form>
<input type="text" name="id" placeholder="Video ID or URL" />
<input type="submit" value="Play" />
</form>
<div id="loader"></div>
<div id="video_container">
<video id="video"></video>
<h2 id="title"></h2>
<div id="metadata"></div>
<hr />
<div id="description"></div>
</div>
<footer>
<p>Powered by <a href="https://github.com/LuanRT/YouTube.js">YouTube.js</a></p>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -2,15 +2,24 @@ import './style.css';
import { Innertube, UniversalCache } from '../../../../bundle/browser';
import dashjs from 'dashjs';
const description = document.getElementById('description') as HTMLDivElement;
const form = document.querySelector('form') as HTMLFormElement;
const title = document.getElementById('title') as HTMLHeadingElement;
const metadata = document.getElementById('metadata') as HTMLDivElement;
const loader = document.getElementById('loader') as HTMLDivElement;
const video = document.getElementById('video') as HTMLVideoElement;
const video_container = document.getElementById('video_container') as HTMLDivElement;
async function main() {
const yt = await Innertube.create({
generate_session_locally: true,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
// url
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
? input
: new URL(input.url);
// transform the url for use with our proxy
url.searchParams.set('__host', url.host);
@@ -20,12 +29,15 @@ async function main() {
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
? input.headers
: new Headers();
// now serialize the headers
url.searchParams.set('__headers', JSON.stringify([...headers]));
// @ts-ignore
input.duplex = 'half';
// copy over the request
const request = new Request(
url,
@@ -42,58 +54,105 @@ async function main() {
headers
});
},
cache: new UniversalCache(),
cache: new UniversalCache(false),
});
const span = document.getElementById('video_name') as HTMLSpanElement;
const form = document.querySelector('form') as HTMLFormElement;
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
form.style.display = 'block';
span.textContent = 'Library ready';
showUI(false);
let player: dashjs.MediaPlayerClass | undefined;
form.addEventListener('submit', async (e) => {
e.preventDefault();
span.textContent = 'Loading...';
if (player) {
player.reset();
}
const video_id = document.querySelector<HTMLInputElement>(
'input[type=text]',
)?.value;
if (!video_id) {
span.textContent = 'No video id';
hideUI();
let video_id;
const video_id_or_url = document.querySelector<HTMLInputElement>('input[type=text]')?.value;
if (!video_id_or_url) {
title.textContent = 'No video id or URL provided';
showUI(false);
return;
}
try {
const video = await yt.getInfo(video_id);
if (video_id_or_url.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
const endpoint = await yt.resolveURL(video_id_or_url);
console.log(video);
span.textContent = video.basic_info.title || null;
if (!endpoint.payload.videoId) {
title.textContent = 'Could not resolve URL';
showUI(false);
return;
}
const dash = video.toDash((url) => {
video_id = endpoint.payload.videoId;
} else {
video_id = video_id_or_url;
}
const info = await yt.getInfo(video_id);
title.textContent = info.basic_info.title || null;
description.innerHTML = info.secondary_info?.description.toHTML() || '';
title.textContent = info.basic_info.title || null;
document.title = info.basic_info.title || '';
metadata!.innerHTML = '';
metadata!.innerHTML += `<div class="metadata_item">${info.primary_info?.published.toHTML()}</div>`;
metadata!.innerHTML += `<div class="metadata_item">${info.primary_info?.view_count.toHTML()}</div>`;
metadata!.innerHTML += `<div class="metadata_item">${info.basic_info.like_count} likes</div>`;
showUI(true);
const dash = info.toDash((url) => {
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
return url;
});
const uri = 'data:application/dash+xml;charset=utf-8;base64,' +
btoa(dash);
const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash);
// create and append video element
const video_element = document.querySelector('video') as HTMLVideoElement;
video_element.setAttribute('controls', 'true');
video_element.poster = info.basic_info.thumbnail![0].url;
// use dash.js to parse the manifest
if (player) {
player.destroy();
}
player = dashjs.MediaPlayer().create();
player.initialize(video_element, uri, true);
player.setInitialMediaSettingsFor('audio', { lang: 'en-US' });
} catch (error) {
span.textContent = 'An error occurred (see console)';
title.textContent = 'An error occurred (see console)';
showUI(false);
console.error(error);
}
});
}
main();
function showUI(with_video = true) {
loader.style.display = 'none';
video.style.display = with_video ? 'block' : 'none';
video_container.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
video_container.style.display = 'block';
}
function hideUI() {
video_container.style.display = 'none';
loader.style.display = 'block';
}
main();

View File

@@ -3,10 +3,88 @@ body {
flex-direction: column;
align-items: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: rgb(32, 32, 32);
color: rgb(255, 255, 255);
}
hr {
height: 1px;
width: 100%;
border: 1px solid transparent;
background-color: rgb(68, 68, 68);
}
form {
margin: 0.5rem 0;
display: none;
}
#loader {
display: block;
border: 10px solid rgb(68, 68, 68);
border-top: 10px solid rgb(255, 255, 255);
border-radius: 50%;
width: 50px;
height: 50px;
align-self: center;
animation: spin 1s linear infinite;
margin: 0.5rem 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#video_container {
display: none;
flex-direction: column;
position: relative;
width: 70vw !important;
margin: 0.5rem 0;
}
#metadata {
display: flex;
flex-direction: row;
align-self: left;
margin: 0.5rem 0;
}
#metadata > .metadata_item {
margin: 0 0.3rem;
background-color: beige;
color: black;
font: 1em bold;
padding: 0.2rem 0.5rem;
border-radius: 0.3rem;
}
#video_container > #description {
align-self: left;
margin-left: 0.5rem;
font-size: medium;
}
video {
max-width: calc(100vw - 1rem);
width: fit-content;
max-height: calc(90vh - 12rem);
width: 100%;
height: 40vw;
}
footer {
margin: 0.5rem 0;
}
@media screen and (max-width: 768px) {
video {
height: auto;
}
#video_container {
width: 100% !important;
}
}

View File

@@ -1,39 +1,47 @@
import { Innertube, UniversalCache } from 'youtubei.js';
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
console.info('Viewing channel:', channel.header.author.name);
console.info('Family Safe:', channel.metadata.is_family_safe);
if (channel.header?.is(YTNodes.C4TabbedHeader)) {
console.info('Viewing channel:', channel?.header?.author.name);
console.info('Family Safe:', channel.metadata.is_family_safe);
}
const about = await channel.getAbout();
console.info('Country:', about.country.toString());
console.info('\nLists the following videos:');
console.info('\nVideos:');
const videos = await channel.getVideos();
for (const video of videos.videos) {
console.info('Video:', video.title.toString());
}
console.info('\nLists the following playlists:');
console.info('\nPopular videos:');
const popular_videos = await videos.applyFilter('Popular');
for (const video of popular_videos.videos) {
console.info('Video:', video.title.toString());
}
console.info('\nPlaylists:');
const playlists = await channel.getPlaylists();
for (const playlist of playlists.playlists) {
console.info('Playlist:', playlist.title.toString());
}
console.info('\nLists the following channels:');
console.info('\nChannels:');
const channels = await channel.getChannels();
for (const channel of channels.channels) {
console.info('Channel:', channel.author.name);
}
console.info('\nLists the following community posts:');
console.info('\nCommunity posts:');
const posts = await channel.getCommunity();
for (const post of posts.posts) {

View File

@@ -1,5 +1,5 @@
## Comment
Contains information about a single comment. A [`Comment`](../../lib/parser/contents/classes/Comment.js) can be a top-level comment or a reply to a top-level comment.
Contains information about a single comment. A [`Comment`](../../src/parser/classes/comments/Comment.ts) can be a top-level comment or a reply to a top-level comment.
## API

View File

@@ -9,27 +9,42 @@ A `CommentThread` represents a top-level comment and its replies.
* [.replies](#replies) ⇒ `Comment[]`
* [.getReplies](#getreplies) ⇒ `function`
* [.getContinuation](#getcontinuation) ⇒ `function`
* [.has_continuation](#hascontinuation) ⇒ `boolean`
* [.has_replies](#hasreplies) ⇒ `boolean`
<a name="comment"></a>
### comment
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
**Type:** [`Comment`](../../lib/parser/contents/classes/Comment.js)
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
<a name="replies"></a>
### replies
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
**Type:** [`Comment[]`](../../lib/parser/contents/classes/Comment.js)
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
<a name="getreplies"></a>
### getReplies()
Retrieves replies to the top-level comment and attaches a [`replies`](#replies) array to the original `CommentThread` object and returns it.
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
<a name="getcontinuation"></a>
### getContinuation()
Retrieves next batch of replies and adds them to the [`replies`](#replies) array. **Note:** [`getReplies()`](#getreplies) must be called before using this.
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
<a name="hascontinuation"></a>
### has_continuation
Whether there are more replies to be retrieved.
**Type:** `boolean`
<a name="hasreplies"></a>
### has_replies
Whether there are replies to the top-level comment.
**Type:** `boolean`

View File

@@ -1,8 +1,8 @@
## Comments
YouTube.js has full support for comments, including comment actions such as liking, disliking, replying etc.
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
## Usage
Get a [`Comments`](../../lib/parser/youtube/Comments.js) instance:
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
```js
const comments = await yt.getComments(VIDEO_ID);
@@ -11,15 +11,27 @@ const comments = await yt.getComments(VIDEO_ID);
## API
* Comments
* [.contents](#commentthread) ⇒ `CommentThread[]`
* [.applySort](#applysort) ⇒ `function`
* [.createComment](#createComment) ⇒ `function`
* [.getContinuation](#getc) ⇒ `function`
* [.has_continuation](#has_continuation) ⇒ `getter`
* [.page](#page) ⇒ `getter`
<a name="commentthread"></a>
### contents
A list of comment threads. **Note:** More about comment threads [**here**](./CommentThread.md).
**Type:** [`CommentThread[]`](../../lib/parser/contents/classes/CommentThread.js)
**Type:** [`CommentThread[]`](../../src/parser/classes/comments/CommentThread.ts)
<a name="applysort"></a>
### applySort(sort)
Applies given sort option to the comments.
| Param | Type | Description |
| --- | --- | --- |
| sort | `string` | Sort option. Can be `TOP_COMMENTS`, `NEWEST_FIRST` |
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
<a name="createComment"></a>
### createComment(text)
@@ -35,7 +47,13 @@ Creates a top-level comment.
### getContinuation()
Retrieves next batch of comment threads.
**Returns:** [`Promise.<Comments>`](../../lib/parser/youtube/Comments.ts)
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
<a name="has_continuation"></a>
### has_continuation
Returns whether there are more comments to be fetched.
**Type:** `boolean`
<a name="page"></a>
### page

View File

@@ -1,39 +1,45 @@
import { Innertube, UniversalCache } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const comments = await yt.getComments('a-rqu-hjobc');
console.info(`This video has ${comments.header?.comments_count.toString() || 'N/A'} comments.\n`);
const comment_section = await yt.getComments('a-rqu-hjobc');
for (const thread of comments.contents) {
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
for (const thread of comment_section.contents) {
const comment = thread.comment;
if (comment) {
console.info(
`${comment.is_pinned ? '[Pinned]' : ''}`,
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
`${comment.author.name}${comment.published}\n`,
`${comment.content.toString()}`, '\n',
`Likes: ${comment.vote_count.short_text}`, '\n'
`Likes: ${comment.vote_count}`, '\n'
);
if (comment.reply_count > 0) {
if (thread.has_replies) {
console.info('Replies:', '\n');
const comment_thread = await thread.getReplies();
if (comment_thread.replies) {
for (const reply of comment_thread.replies) {
let comment_thread = await thread.getReplies();
while (true) {
for (const reply of comment_thread?.replies || []) {
console.info(
`> ${reply.author.name}${reply.published}\n`,
`${reply.content.toString()}`, '\n',
`Likes: ${reply.vote_count.short_text}`, '\n'
`Likes: ${reply.vote_count}`, '\n'
);
}
try {
comment_thread = await comment_thread.getContinuation();
} catch { break; };
}
}
}
console.log('\n');
}
})();

View File

@@ -1,4 +1,4 @@
import { Innertube } from '../../bundle/browser.js';
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
const yt = await Innertube.create();

View File

@@ -1,45 +1,44 @@
import { Innertube, UniversalCache } from 'youtubei.js';
import { readFileSync, existsSync, mkdirSync, createWriteStream } from 'fs';
import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
import { Innertube, UniversalCache, Utils } from 'youtubei.js';
import { existsSync, mkdirSync, createWriteStream } from 'fs';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
if (!search.results)
throw new Error('Filter "type" must be used');
const album = await yt.music.getAlbum(search.results[0].id as string);
if (!album.contents)
throw new Error('Album appears to be empty');
console.info(`Album "${album.header.title.toString()}" by ${album.header.author?.name}`, '\n');
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
type: 'audio', // audio, video or audio+video
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'mp4' // media container format
});
console.info(`Downloading ${song.title} (${song.id})`);
const dir = `./${album.header.title.toString()}`;
const dir = `./${album.header?.title.toString()}`;
if (!existsSync(dir)) {
mkdirSync(dir);
}
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
for await (const chunk of streamToIterable(stream)) {
for await (const chunk of Utils.streamToIterable(stream)) {
file.write(chunk);
}
console.info(`${song.id} - Done!`, '\n');
}
console.info(`Downloaded ${album.header.song_count}!`);
console.info(`Downloaded ${album.header?.song_count}!`);
})();

View File

@@ -1,16 +1,16 @@
## Live Chat
The library's Live Chat parser and poller were heavily based on YouTube's original compiled code, this makes it behave in a similar if not identical way to YouTube's Live Chat. Here you can do all sorts of funny things, ex; track messages, donations, polls, and much more.
Represents a livestream chat.
## Usage
Before fetching a Live Chat, you have to retrieve the target livestream's info:
Before fetching a live chat, you have to retrieve the target livestream's info:
```js
const info = await yt.getInfo('video_id');
```
Then you may request a Live Chat instance:
Then you may request a live chat instance:
```js
const livechat = await info.getLiveChat();
```
@@ -21,6 +21,7 @@ const livechat = await info.getLiveChat();
* [.ev](#ev) ⇒ `EventEmitter`
* [.start](#start) ⇒ `function`
* [.stop](#stop) ⇒ `function`
* [.applyFilter](#applyfilter) ⇒ `function`
* [.getItemMenu](#getitemmenu) ⇒ `function`
* [.sendMessage](#sendmessage) ⇒ `function`
@@ -31,6 +32,8 @@ Live Chat's EventEmitter.
**Events:**
- `start`
Fired when the live chat is started.
Arguments:
| Type | Description |
@@ -38,18 +41,35 @@ Live Chat's EventEmitter.
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
- `chat-update`
Fired when a new chat action is received.
Arguments:
| Type | Description |
| --- | --- |
| `ChatAction` | Chat Action |
| `ChatAction` | Chat action |
- `metadata-update`
Fired when the livestream's metadata is updated.
Arguments:
| Type | Description |
| --- | --- |
| `LiveMetadata` | LiveStream Metadata |
| `LiveMetadata` | Livestream metadata |
- `error`
Fired when an error occurs.
Arguments:
| Type | Description |
| --- | --- |
| `Error` | Details about the error |
- `end`
Fired when the livestream ends.
<a name="start"></a>
### start()
@@ -59,6 +79,15 @@ Starts the Live Chat.
### stop()
Stops the Live Chat.
<a name="applyfilter"></a>
### applyFilter(filter)
Applies given filter to the live chat.
| Param | Type | Description |
| --- | --- | --- |
| filter | `string` | Can be `TOP_CHAT` or `LIVE_CHAT` |
<a name="getitemmenu"></a>
### getItemMenu(item)
Retrieves given chat item's menu.

View File

@@ -1,31 +1,37 @@
import { Innertube, UniversalCache } from 'youtubei.js';
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
import LiveChat, { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
import Video from 'youtubei.js/dist/src/parser/classes/Video';
import AddChatItemAction from 'youtubei.js/dist/src/parser/classes/livechat/AddChatItemAction';
import MarkChatItemAsDeletedAction from 'youtubei.js/dist/src/parser/classes/livechat/MarkChatItemAsDeletedAction';
import LiveChatTextMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatTextMessage';
import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/items/LiveChatPaidMessage';
import { Innertube, UniversalCache, YTNodes, LiveChatContinuation } from 'youtubei.js';
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const search = await yt.search('Lofi girl live');
const info = await yt.getInfo(search.videos[0].as(Video).id);
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const search = await yt.search('lofi hip hop radio - beats to relax/study to');
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
const livechat = info.getLiveChat();
const livechat = await info.getLiveChat();
livechat.on('start', (initial_data: LiveChatContinuation) => {
/**
* Initial info is what you see when you first open a Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
* Initial info is what you see when you first open a a live chat — this is; initial actions (pinned messages, top donations..), account's info and so forth.
*/
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
console.info(`Hey ${initial_data.viewer_name || 'Guest'}, welcome to Live Chat!`);
const pinned_action = initial_data.actions.firstOfType(YTNodes.AddBannerToLiveChatCommand);
if (pinned_action) {
if (pinned_action.banner?.contents?.is(YTNodes.LiveChatTextMessage)) {
console.info(
'\n', 'Pinned message:\n',
pinned_action.banner.contents.author?.name.toString(), '-', pinned_action?.banner.contents.message.toString(),
'\n'
);
}
}
});
livechat.on('error', (error: Error) => console.info('Live chat error:', error));
livechat.on('end', () => console.info('This live stream has ended.'));
livechat.on('chat-update', (action: ChatAction) => {
/**
* An action represents what is being added to
@@ -35,28 +41,38 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
* Below are a few examples of how this can be used.
*/
if (action.is(AddChatItemAction)) {
const item = action.as(AddChatItemAction).item;
if (action.is(YTNodes.AddChatItemAction)) {
const item = action.as(YTNodes.AddChatItemAction).item;
if (!item)
return console.info('Action did not have an item.', action);
const hours = new Date(item.hasKey('timestamp') ? item.timestamp : Date.now()).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
switch (item.type) {
case 'LiveChatTextMessage':
console.info(
`${hours} - ${item.as(LiveChatTextMessage).author?.name.toString()}:\n` +
`${item.as(LiveChatTextMessage).message.toString()}\n`
`${item.as(YTNodes.LiveChatTextMessage).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
);
break;
case 'LiveChatPaidMessage':
console.info(
`${hours} - ${item.as(LiveChatPaidMessage).author.name.toString()}:\n` +
`${item.as(LiveChatPaidMessage).purchase_amount}\n`
`${item.as(YTNodes.LiveChatPaidMessage).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatPaidMessage).message.toString()}\n`,
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
);
break;
case 'LiveChatPaidSticker':
console.info(
`${item.as(YTNodes.LiveChatPaidSticker).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatPaidSticker).author.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatPaidSticker).purchase_amount}\n`
);
break;
default:
@@ -64,9 +80,17 @@ import LiveChatPaidMessage from 'youtubei.js/dist/src/parser/classes/livechat/it
break;
}
}
if (action.is(MarkChatItemAsDeletedAction)) {
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\n');
if (action.is(YTNodes.AddBannerToLiveChatCommand)) {
console.info('Message pinned:', action.banner?.contents);
}
if (action.is(YTNodes.RemoveBannerForLiveChatCommand)) {
console.info(`Message with action id ${action.target_action_id} was unpinned.`);
}
if (action.is(YTNodes.RemoveChatItemAction)) {
console.warn(`Message with action id ${action.target_item_id} just got deleted!`, '\n');
}
});

View File

@@ -1,22 +1,11 @@
import { Parser } from 'youtubei.js';
import SectionList from 'youtubei.js/dist/src/parser/classes/SectionList';
import SingleColumnBrowseResults from 'youtubei.js/dist/src/parser/classes/SingleColumnBrowseResults';
import MusicVisualHeader from 'youtubei.js/dist/src/parser/classes/MusicVisualHeader';
import MusicImmersiveHeader from 'youtubei.js/dist/src/parser/classes/MusicImmersiveHeader';
import MusicCarouselShelf from 'youtubei.js/dist/src/parser/classes/MusicCarouselShelf';
import MusicDescriptionShelf from 'youtubei.js/dist/src/parser/classes/MusicDescriptionShelf';
import MusicShelf from 'youtubei.js/dist/src/parser/classes/MusicShelf';
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(MusicImmersiveHeader, MusicVisualHeader);
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
console.info('Header:', header);
@@ -24,14 +13,14 @@ console.info('Header:', header);
// 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(SingleColumnBrowseResults).tabs.get({ selected: false });
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(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf, MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);

View File

@@ -5,7 +5,7 @@ const creds_path = './my_yt_creds.json';
const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toString()) : undefined;
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(false) });
yt.session.on('auth-pending', (data: any) => {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.user_code}`);
@@ -31,5 +31,5 @@ const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toStr
privacy: 'UNLISTED'
});
console.info('Done!');
console.info('Done!', upload);
})();

View File

@@ -1,26 +0,0 @@
import { getRuntime } from './src/utils/Utils';
// Polyfill fetch for node
if (getRuntime() === 'node') {
// eslint-disable-next-line
const undici = require('undici');
Reflect.set(globalThis, 'fetch', undici.fetch);
Reflect.set(globalThis, 'Headers', undici.Headers);
Reflect.set(globalThis, 'Request', undici.Request);
Reflect.set(globalThis, 'Response', undici.Response);
Reflect.set(globalThis, 'FormData', undici.FormData);
Reflect.set(globalThis, 'File', undici.File);
try {
// eslint-disable-next-line
const { ReadableStream } = require('node:stream/web');
Reflect.set(globalThis, 'ReadableStream', ReadableStream);
} catch { /* do nothing */ }
}
import Innertube from './src/Innertube';
export * from './src/utils';
export { YTNodes } from './src/parser/map';
export { default as Parser } from './src/parser';
export { default as Innertube } from './src/Innertube';
export default Innertube;

View File

@@ -1,6 +1,6 @@
module.exports = {
export default {
projects: [
{
displayName: 'node',

4138
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,53 @@
{
"name": "youtubei.js",
"version": "2.3.2",
"description": "Full-featured wrapper around YouTube's private API.",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",
"types": "./dist",
"version": "3.1.1",
"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 <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": [
"https://github.com/sponsors/LuanRT"
@@ -24,12 +67,14 @@
"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 bundle:browser && npm run bundle:browser:prod && npm run build:node",
"build:node": "npx tsc",
"bundle:browser": "npx tsc --module esnext && npx esbuild ./dist/browser.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --outfile=./bundle/browser.js --platform=browser",
"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 \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'linkedom';\" \"'https://esm.sh/linkedom';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r && npx replace \"new Jinter.default\" \"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",
"build:parser-map": "node ./scripts/build-parser-map.js",
"build:proto": "npx protoc --ts_out ./src/proto --proto_path ./src/proto ./src/proto/youtube.proto",
"prepare": "npm run build",
"watch": "npx tsc --watch"
},
@@ -39,24 +84,25 @@
},
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.7.0",
"jintr": "^0.3.1",
"jintr": "^0.4.1",
"linkedom": "^0.14.12",
"undici": "^5.7.0"
"undici": "^5.19.1"
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.7.0",
"@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.7.4"
"typescript": "^4.9.5"
},
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
@@ -71,7 +117,7 @@
"youtube-dl",
"youtube-downloader",
"youtube-music",
"innertubeapi",
"youtube-studio",
"innertube",
"unofficial",
"downloader",
@@ -80,7 +126,6 @@
"upload",
"ytmusic",
"search",
"comment",
"music",
"api"
]

View File

@@ -5,22 +5,31 @@ const path = require('path');
const import_list = [];
const json = [];
const misc_exports = [];
glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
.forEach((file) => {
if (file.includes('/misc/')) return;
// Trim path
const is_misc = file.includes('/misc/');
file = file.replace('../src/parser/classes/', '').replace('.js', '').replace('.ts', '');
const import_name = file.split('/').pop();
import_list.push(`import { default as ${import_name} } from './classes/${file}';`);
json.push(import_name);
if (is_misc) {
const class_name = file.split('/').pop().replace('.js', '').replace('.ts', '');
import_list.push(`import { default as ${class_name} } from './classes/${file}.js';`);
misc_exports.push(class_name);
} else {
import_list.push(`import { default as ${import_name} } from './classes/${file}.js';
export { ${import_name} };`);
json.push(import_name);
}
});
fs.writeFileSync(
path.resolve(__dirname, '../src/parser/map.ts'),
`// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js
import { YTNodeConstructor } from './helpers';
import { YTNodeConstructor } from './helpers.js';
${import_list.join('\n')}
@@ -28,7 +37,9 @@ const map: Record<string, YTNodeConstructor> = {
${json.join(',\n ')}
};
export const YTNodes = map;
export const Misc = {
${misc_exports.join(',\n ')}
};
/**
* @param name - Name of the node to be parsed

52
scripts/get-agents.js Normal file
View File

@@ -0,0 +1,52 @@
import { fetch } from 'undici';
import { gunzip } from 'zlib';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { writeFile } from 'fs/promises';
(async () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const buf = await (await fetch('https://github.com/intoli/user-agents/blob/master/src/user-agents.json.gz?raw=true')).arrayBuffer();
const bytes = new Uint8Array(buf);
// Only get desktop and mobile agents
const allowed_agents = new Set([
'desktop',
'mobile'
]);
const decompressed = await new Promise((resolve, reject) => {
gunzip(bytes, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result.buffer);
}
});
});
const contents = new TextDecoder().decode(decompressed);
const agents = JSON.parse(contents);
if (!Array.isArray(agents)) {
throw new Error('Invalid user-agents.json');
}
const agentsByDevice = agents.reduce((acc, agent) => {
const device = agent.deviceCategory;
if (!allowed_agents.has(device))
return acc;
if (!acc[device]) {
acc[device] = [];
}
// We dont want to massive of a list of agents for each device
if (acc[device].length <= 25) acc[device].push(agent.userAgent);
return acc;
}, {});
await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.ts'), `/* eslint-disable */\n/* Generated file do not edit */\nexport default ${JSON.stringify(agentsByDevice, null, 2)} as { desktop: string[], mobile: string[] };`);
})();

View File

@@ -1,52 +0,0 @@
import { fetch } from "undici";
import { gunzip } from "zlib";
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { writeFile } from "fs/promises";
(async () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const buf = await (await fetch('https://github.com/intoli/user-agents/blob/master/src/user-agents.json.gz?raw=true')).arrayBuffer();
const bytes = new Uint8Array(buf);
// Only get desktop and mobile agents
const allowed_agents = new Set([
'desktop',
'mobile',
])
const decompressed = await new Promise((resolve, reject) => {
gunzip(bytes, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result.buffer);
}
});
});
const contents = new TextDecoder().decode(decompressed);
const agents = JSON.parse(contents);
if (!Array.isArray(agents)) {
throw new Error('Invalid user-agents.json');
}
const agentsByDevice = agents.reduce((acc, agent) => {
const device = agent.deviceCategory;
if (!allowed_agents.has(device))
return acc;
if (!acc[device]) {
acc[device] = [];
}
// we dont want to massive of a list of agents for each device
if (acc[device].length <= 25) acc[device].push(agent.userAgent);
return acc;
}, {});
await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.json'), JSON.stringify(agentsByDevice, null, 2));
})();

View File

@@ -1,63 +1,58 @@
import Session, { SessionOptions } from './core/Session';
import Session, { SessionOptions } from './core/Session.js';
import Search from './parser/youtube/Search';
import Channel from './parser/youtube/Channel';
import Playlist from './parser/youtube/Playlist';
import Library from './parser/youtube/Library';
import History from './parser/youtube/History';
import Comments from './parser/youtube/Comments';
import NotificationsMenu from './parser/youtube/NotificationsMenu';
import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo';
import NavigationEndpoint from './parser/classes/NavigationEndpoint';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';
import Channel from './parser/youtube/Channel.js';
import Comments from './parser/youtube/Comments.js';
import History from './parser/youtube/History.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 { ParsedResponse } from './parser';
import { ActionsResponse } from './core/Actions';
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 Proto from './proto/index.js';
import Constants from './utils/Constants.js';
import Feed from './core/Feed';
import YTMusic from './core/Music';
import Studio from './core/Studio';
import AccountManager from './core/AccountManager';
import PlaylistManager from './core/PlaylistManager';
import InteractionManager from './core/InteractionManager';
import FilterableFeed from './core/FilterableFeed';
import TabbedFeed from './core/TabbedFeed';
import Constants from './utils/Constants';
import Proto from './proto/index';
import type Actions from './core/Actions.js';
import type Format from './parser/classes/misc/Format.js';
import { throwIfMissing, generateRandomString } from './utils/Utils';
import type { ApiResponse } from './core/Actions.js';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js';
import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.js';
import { generateRandomString, throwIfMissing } from './utils/Utils.js';
export type InnertubeConfig = SessionOptions
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
/**
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
*/
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
/**
* Filter results by type, can be: any | video | channel | playlist | movie
*/
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
/**
* Filter videos by duration, can be: any | short | medium | long
*/
duration?: 'any' | 'short' | 'medium' | 'long';
/**
* Filter video results by order, can be: relevance | rating | upload_date | view_count
*/
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' | 'TV_EMBEDDED';
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS'
class Innertube {
session;
account;
playlist;
interact;
music;
studio;
actions;
session: Session;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
music: YTMusic;
studio: YTStudio;
kids: YTKids;
actions: Actions;
constructor(session: Session) {
this.session = session;
@@ -65,22 +60,27 @@ class Innertube {
this.playlist = new PlaylistManager(this.session.actions);
this.interact = new InteractionManager(this.session.actions);
this.music = new YTMusic(this.session);
this.studio = new Studio(this.session);
this.studio = new YTStudio(this.session);
this.kids = new YTKids(this.session);
this.actions = this.session.actions;
}
static async create(config: InnertubeConfig = {}) {
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
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) {
async getInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
const continuation = this.actions.next({ video_id });
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);
@@ -88,8 +88,12 @@ class Innertube {
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const response = await this.actions.getVideoInfo(video_id, cpn, client);
@@ -98,18 +102,27 @@ class Innertube {
/**
* Searches a given query.
* @param query - search query.
* @param filters - search filters.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}) {
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.actions.search({ query, filters });
return new Search(this.actions, response.data);
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.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
@@ -134,94 +147,116 @@ class Innertube {
/**
* Retrieves comments for a video.
* @param video_id - the video id.
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.next({ ctoken: payload });
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() {
const response = await this.actions.browse('FEwhat_to_watch');
return new FilterableFeed(this.actions, response.data);
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new HomeFeed(this.actions, response);
}
/**
* Returns the account's library.
*/
async getLibrary() {
const response = await this.actions.browse('FElibrary');
return new Library(response.data, this.actions);
async getLibrary(): Promise<Library> {
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() {
const response = await this.actions.browse('FEhistory');
return new History(this.actions, response.data);
async getHistory(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
async getTrending() {
const response = await this.actions.browse('FEtrending');
return new TabbedFeed(this.actions, response.data);
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true });
return new TabbedFeed(this.actions, response);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed() {
const response = await this.actions.browse('FEsubscriptions');
return new Feed(this.actions, response.data);
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
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
* @param id - Channel id
*/
async getChannel(id: string) {
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.browse(id);
return new Channel(this.actions, response.data);
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response);
}
/**
* Retrieves notifications.
*/
async getNotifications() {
const response = await this.actions.notifications('get_notification_menu');
async getNotifications(): Promise<NotificationsMenu> {
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() {
const response = await this.actions.notifications('get_unseen_count');
return response.data.unseenCount;
async getUnseenNotificationsCount(): Promise<number> {
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) {
async getPlaylist(id: string): Promise<Playlist> {
throwIfMissing({ id });
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
return new Playlist(this.actions, response.data);
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<HashtagFeed> {
throwIfMissing({ hashtag });
const params = Proto.encodeHashtag(hashtag);
const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params });
return new HashtagFeed(this.actions, response);
}
/**
@@ -229,27 +264,44 @@ class Innertube {
* 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 = {}) {
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
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) {
async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
const info = await this.getBasicInfo(video_id, options?.client);
return info.download(options);
}
call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
return endpoint.callTest(this.actions, args);
/**
* Resolves the given URL.
* @param url - The URL.
*/
async resolveURL(url: string): Promise<NavigationEndpoint> {
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<T extends IParsedResponse>(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<T>;
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<IParsedResponse | ApiResponse> {
return endpoint.call(this.actions, args);
}
}
export default Innertube;
export default Innertube;

View File

@@ -1,14 +1,22 @@
import Proto from '../proto/index';
import Actions from './Actions';
import Proto from '../proto/index.js';
import type Actions from './Actions.js';
import type { ApiResponse } from './Actions.js';
import Analytics from '../parser/youtube/Analytics';
import TimeWatched from '../parser/youtube/TimeWatched';
import AccountInfo from '../parser/youtube/AccountInfo';
import Settings from '../parser/youtube/Settings';
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 { InnertubeError } from '../utils/Utils.js';
class AccountManager {
#actions;
channel;
#actions: Actions;
channel: {
editName: (new_name: string) => Promise<ApiResponse>;
editDescription: (new_description: string) => Promise<ApiResponse>;
getBasicAnalytics: () => Promise<Analytics>;
};
constructor(actions: Actions) {
this.#actions = actions;
@@ -16,13 +24,30 @@ class AccountManager {
this.channel = {
/**
* Edits channel name.
* @param new_name - The new channel name.
*/
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_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) => this.#actions.channel('channel/edit_description', { 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.
*/
@@ -33,7 +58,10 @@ class AccountManager {
/**
* Retrieves channel info.
*/
async getInfo() {
async getInfo(): Promise<AccountInfo> {
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);
}
@@ -41,7 +69,7 @@ class AccountManager {
/**
* Retrieves time watched statistics.
*/
async getTimeWatched() {
async getTimeWatched(): Promise<TimeWatched> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPtime_watched',
client: 'ANDROID'
@@ -53,7 +81,7 @@ class AccountManager {
/**
* Opens YouTube settings.
*/
async getSettings() {
async getSettings(): Promise<Settings> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPaccount_overview'
});
@@ -64,11 +92,16 @@ class AccountManager {
/**
* Retrieves basic channel analytics.
*/
async getAnalytics() {
async getAnalytics(): Promise<Analytics> {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
return new Analytics(response);
}

View File

@@ -1,70 +1,49 @@
import Proto from '../proto/index';
import Session from './Session';
import Parser, { NavigateAction } from '../parser/index.js';
import { InnertubeError } from '../utils/Utils.js';
import Parser, { ParsedResponse } from '../parser/index';
import type Session from './Session.js';
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
INextResponse, IPlayerResponse, IResolveURLResponse,
ISearchResponse, IUpdatedMetadataResponse,
IParsedResponse, IRawResponse
} from '../parser/types/index.js';
export interface BrowseArgs {
params?: string | null;
is_ytm?: boolean;
is_ctoken?: boolean;
form_data?: {};
client?: string;
}
export interface EngageArgs {
video_id?: string;
channel_id?: string;
comment_id?: string;
comment_action?: string;
params?: string;
text?: string;
target_language?: string;
}
export interface AccountArgs {
new_value?: string | boolean; // TODO: is this correct?
setting_item_id?: string;
client?: string;
}
export interface SearchArgs {
query?: string,
options?: {
period?: string,
duration?: string,
order?: string
},
client?: string,
ctoken?: string,
params?: string
filters?: any // TODO: what is this type??
}
export interface AxioslikeResponse {
export interface ApiResponse {
success: boolean;
status_code: number;
data: any;
data: IRawResponse;
}
export type ActionsResponse = Promise<AxioslikeResponse>;
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type ParsedResponse<T> =
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: Session;
constructor(session: Session) {
this.#session = session;
}
get 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) {
async #wrap(response: Response): Promise<ApiResponse> {
return {
success: response.ok,
status_code: response.status,
@@ -72,552 +51,14 @@ class Actions {
};
}
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param id - browseId or a continuation token
* @param args - additional arguments
*/
async browse(id: string, args: BrowseArgs = {}) {
if (this.#needsLogin(id) && !this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
if (args.params)
data.params = args.params;
if (args.is_ctoken) {
data.continuation = id;
} else {
data.browseId = id;
}
if (args.form_data) {
data.formData = args.form_data;
}
if (args.client) {
data.client = args.client;
}
const response = await this.#session.http.fetch('/browse', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*/
async engage(action: string, args: EngageArgs = {}) {
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.target = {};
data.target.videoId = args.video_id;
if (args.params) {
data.params = args.params;
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
if (!hasKeys(args, 'channel_id'))
throw new MissingParamError('Arguments lacks channel_id');
data.channelIds = [ args.channel_id ];
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const target_action = (() => {
switch (args.comment_action) {
case 'like':
return Proto.encodeCommentActionParams(5, args);
case 'dislike':
return Proto.encodeCommentActionParams(4, args);
case 'translate':
return Proto.encodeCommentActionParams(22, args);
default:
break;
}
})();
data.actions = [ target_action ];
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints related to account management.
*/
async account(action: string, args: AccountArgs = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'account/set_setting':
data.newValue = {
boolValue: args.new_value
};
data.settingItemId = args.setting_item_id;
break;
case 'account/accounts_list':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used for search.
*/
async search(args: SearchArgs = {}) {
const data: Record<string, any> = { client: args.client };
if (args.query) {
data.query = args.query;
}
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.params) {
data.params = args.params;
}
if (args.filters) {
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
data.params = Proto.encodeMusicSearchFilters(args.filters);
} else {
data.params = Proto.encodeSearchFilters(args.filters);
}
}
const response = await this.#session.http.fetch('/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used fo Shorts' sound search.
*/
async searchSound(args: { query: string; }) {
const data = {
query: args.query,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/sfv/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Channel management endpoints.
*/
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client || 'ANDROID' };
switch (action) {
case 'channel/edit_name':
data.givenName = args.new_name;
break;
case 'channel/edit_description':
data.description = args.new_description;
break;
case 'channel/get_profile_editor':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for playlist management.
*/
async playlist(action: string, args: {
title?: string;
ids?: string[];
playlist_id?: string;
action?: string;
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'playlist/create':
data.title = args.title;
data.videoIds = args.ids;
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
if (!hasKeys(args, 'ids'))
throw new MissingParamError('Arguments lacks ids');
data.playlistId = args.playlist_id;
data.actions = args.ids.map((id) => {
switch (args.action) {
case 'ACTION_ADD_VIDEO':
return {
action: args.action,
addedVideoId: id
};
case 'ACTION_REMOVE_VIDEO':
return {
action: args.action,
setVideoId: id
};
default:
break;
}
});
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for notifications management.
*/
async notifications(action: string, args: {
pref?: string;
channel_id?: string;
ctoken?: string;
params?: string
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'modify_channel_preference':
if (!hasKeys(args, 'channel_id', 'pref'))
throw new MissingParamError('Arguments lacks channel_id or pref');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
throw new InnertubeError('Invalid preference type', args.pref);
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
break;
case 'get_notification_menu':
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
if (args.ctoken)
data.ctoken = args.ctoken;
break;
case 'record_interactions':
data.serializedRecordNotificationInteractionsRequest = args.params;
break;
case 'get_unseen_count':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/notification/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers livechat endpoints.
*/
async livechat(action: string, args: {
text?: string;
video_id?: string;
channel_id?: string;
ctoken?: string;
params?: string;
client?: string;
} = {}) {
// TODO: should client be required?
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'live_chat/get_live_chat':
case 'live_chat/get_live_chat_replay':
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = uuidv4();
data.richMessage = {
textSegments: [ {
text: args.text
} ]
};
break;
case 'live_chat/get_item_context_menu':
data.params = args.params;
break;
case 'live_chat/moderate':
data.params = args.params;
break;
case 'updated_metadata':
data.videoId = args.video_id;
if (args.ctoken)
data.continuation = args.ctoken;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used to retrieve video thumbnails.
*/
async thumbnails(args: { video_id: string; }) {
const data = {
client: 'ANDROID',
videoId: args.video_id
};
const response = await this.#session.http.fetch('/thumbnails', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*/
async geo(action: string, args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch(`/geo/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to report content.
*/
async flag(action: string, args: { action: string; params?: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'flag/flag':
data.action = args.action;
break;
case 'flag/get_form':
data.params = args.params;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers specific YouTube Music endpoints.
*/
async music(action: string, args: { input?: string; }) {
const data = {
input: args.input || '',
client: 'YTMUSIC'
};
const response = await this.#session.http.fetch(`/music/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Mostly used for pagination and specific operations.
*/
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
const data: Record<string, any> = { client: args.client };
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.video_id) {
data.videoId = args.video_id;
}
if (args.playlist_id) {
data.playlistId = args.playlist_id;
}
if (args.params) {
data.params = args.params;
}
const response = await this.#session.http.fetch('/next', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* 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) {
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ApiResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
@@ -625,8 +66,8 @@ class Actions {
splay: false,
referer: 'https://www.youtube.com',
currentUrl: `/watch?v=${id}`,
autonavState: 'STATE_OFF',
signatureTimestamp: this.#session.player.sts,
autonavState: 'STATE_NONE',
signatureTimestamp: this.#session.player?.sts || 0,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
@@ -661,33 +102,13 @@ class Actions {
return this.#wrap(response);
}
/**
* Endpoint used to retrieve user mention suggestions.
*/
async getUserMentionSuggestions(args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
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 }) {
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
const s_url = new URL(url);
s_url.searchParams.set('ver', '2');
@@ -706,20 +127,20 @@ class Actions {
/**
* Executes an API call.
* @param action - endpoint
* @param args - call arguments
* @param endpoint - The endpoint to call.
* @param args - Call arguments
*/
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
async execute(action: string, args: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
async execute(action: string, args: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
async execute<T extends InnertubeEndpoint>(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise<ParsedResponse<T>>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise<ApiResponse>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse<T> | ApiResponse> {
let data;
if (!args.protobuf) {
if (args && !args.protobuf) {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
throw new InnertubeError('You are not signed in');
throw new InnertubeError('You must be signed in to perform this operation.');
}
if (Reflect.has(data, 'override_endpoint'))
@@ -755,35 +176,51 @@ class Actions {
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else {
} else if (args) {
data = args.serialized_data;
}
const endpoint = Reflect.has(args, 'override_endpoint') ? args.override_endpoint : action;
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
const response = await this.#session.http.fetch(endpoint, {
const response = await this.#session.http.fetch(target_endpoint, {
method: 'POST',
body: args.protobuf ? data : JSON.stringify(data),
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
'Content-Type': args.protobuf ?
'Content-Type': args?.protobuf ?
'application/x-protobuf' :
'application/json'
}
});
if (args.parse) {
return Parser.parseResponse(await response.json());
if (args?.parse) {
let parsed_response = Parser.parseResponse<ParsedResponse<T>>(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'
@@ -791,5 +228,4 @@ class Actions {
}
}
// TODO: maybe do this inferrance in a more elegant way
export default Actions;

View File

@@ -1,59 +1,60 @@
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
import { Memo, ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
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 Post from '../parser/classes/Post';
import BackstagePost from '../parser/classes/BackstagePost';
import BackstagePost from '../parser/classes/BackstagePost.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 Channel from '../parser/classes/Channel';
import CompactVideo from '../parser/classes/CompactVideo';
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 GridChannel from '../parser/classes/GridChannel';
import GridPlaylist from '../parser/classes/GridPlaylist';
import GridVideo from '../parser/classes/GridVideo';
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 Playlist from '../parser/classes/Playlist';
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo';
import PlaylistVideo from '../parser/classes/PlaylistVideo';
import type { IParsedResponse } from '../parser/types/index.js';
import type { ApiResponse } from './Actions.js';
import Tab from '../parser/classes/Tab';
import ReelShelf from '../parser/classes/ReelShelf';
import RichShelf from '../parser/classes/RichShelf';
import Shelf from '../parser/classes/Shelf';
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
import ContinuationItem from '../parser/classes/ContinuationItem';
import Video from '../parser/classes/Video';
// TODO: add a way subdivide into sections and return subfeeds?
class Feed {
#page: ParsedResponse;
class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
#continuation?: ObservedArray<ContinuationItem>;
#actions;
#memo;
#actions: Actions;
#memo: Memo;
constructor(actions: Actions, data: any, already_parsed = false) {
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
this.#page = data;
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(data);
this.#page = Parser.parseResponse<T>(response.data);
}
// Xxx: this can be extremely confusing — maybe refactor?
const memo =
this.#page.on_response_received_commands ?
this.#page.on_response_received_commands_memo :
this.#page.on_response_received_endpoints ?
this.#page.on_response_received_endpoints_memo :
this.#page.contents ?
this.#page.contents_memo :
this.#page.on_response_received_actions ?
this.#page.on_response_received_actions_memo : undefined;
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');
@@ -62,13 +63,18 @@ class Feed {
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<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
Video,
GridVideo,
ReelItem,
CompactVideo,
PlaylistVideo,
PlaylistPanelVideo,
@@ -118,10 +124,10 @@ class Feed {
/**
* Returns contents from the page.
*/
get contents() {
const tab_content = this.#memo.getType(Tab)?.[0]?.content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0];
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0];
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
const tab_content = this.#memo.getType(Tab)?.first().content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand).first();
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction).first();
return tab_content || reload_continuation_items || append_continuation_items;
}
@@ -137,17 +143,17 @@ class Feed {
* Finds shelf by title.
*/
getShelf(title: string) {
return this.shelves.find((shelf) => shelf.title.toString() === title);
return this.shelves.get({ title });
}
/**
* Returns secondary contents from the page.
*/
get secondary_contents() {
if (!this.#page.contents.is_node)
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
if (!this.#page.contents?.is_node)
return undefined;
const node = this.#page.contents.item();
const node = this.#page.contents?.item();
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
return undefined;
@@ -155,35 +161,35 @@ class Feed {
return node.secondary_contents;
}
get actions() {
get actions(): Actions {
return this.#actions;
}
/**
* Get the original page data
*/
get page() {
get page(): T {
return this.#page;
}
/**
* Checks if the feed has continuation.
*/
get has_continuation() {
get has_continuation(): boolean {
return (this.#memo.get('ContinuationItem') || []).length > 0;
}
/**
* Retrieves continuation data as it is.
*/
async getContinuationData(): Promise<ParsedResponse | undefined> {
async getContinuationData(): Promise<T | undefined> {
if (this.#continuation) {
if (this.#continuation.length > 1)
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
const response = await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });
return response;
}
@@ -197,9 +203,11 @@ class Feed {
/**
* Retrieves next batch of contents and returns a new {@link Feed} object.
*/
async getContinuation() {
async getContinuation(): Promise<Feed<T>> {
const continuation_data = await this.getContinuationData();
return new Feed(this.actions, continuation_data, true);
if (!continuation_data)
throw new InnertubeError('Could not get continuation data');
return new Feed<T>(this.actions, continuation_data, true);
}
}

View File

@@ -1,21 +1,24 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar';
import { ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import Feed from './Feed';
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';
class FilterableFeed extends Feed {
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<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
super(actions, data, already_parsed);
}
/**
* Get filters for the feed
* Returns the filter chips.
*/
get filter_chips() {
get filter_chips(): ObservedArray<ChipCloudChip> {
if (this.#chips)
return this.#chips || [];
@@ -30,21 +33,22 @@ class FilterableFeed extends Feed {
return this.#chips || [];
}
get filters() {
/**
* Returns available filters.
*/
get filters(): string[] {
return this.filter_chips.map((chip) => chip.text.toString()) || [];
}
/**
* Applies given filter and returns a new {@link Feed} object.
*/
async getFilteredFeed(filter: string | ChipCloudChip) {
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed<T>> {
let target_filter: ChipCloudChip | undefined;
if (typeof filter === 'string') {
if (!this.filters.includes(filter))
throw new InnertubeError('Filter not found', {
available_filters: this.filters
});
throw new InnertubeError('Filter not found', { available_filters: this.filters });
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
} else if (filter.type === 'ChipCloudChip') {
target_filter = filter;
@@ -54,10 +58,15 @@ class FilterableFeed extends Feed {
if (!target_filter)
throw new InnertubeError('Filter not found');
if (target_filter.is_selected)
return this;
const response = await target_filter.endpoint?.call(this.actions, undefined, true);
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
if (!response)
throw new InnertubeError('Failed to get filtered feed');
return new Feed(this.actions, response, true);
}
}

View File

@@ -1,8 +1,10 @@
import { throwIfMissing } from '../utils/Utils';
import Actions from './Actions';
import Proto from '../proto/index.js';
import type Actions from './Actions.js';
import type { ApiResponse } from './Actions.js';
import { throwIfMissing } from '../utils/Utils.js';
class InteractionManager {
#actions;
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
@@ -10,55 +12,119 @@ class InteractionManager {
/**
* Likes a given video.
* @param video_id - The video ID
*/
async like(video_id: string) {
async like(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_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('/like/like', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Dislikes a given video.
* @param video_id - The video ID
*/
async dislike(video_id: string) {
async dislike(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_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('/like/dislike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Removes a like/dislike.
* @param video_id - The video ID
*/
async removeLike(video_id: string) {
async removeRating(video_id: string): Promise<ApiResponse> {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/removelike', { video_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('/like/removelike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Subscribes to a given channel.
* @param channel_id - The channel ID
*/
async subscribe(channel_id: string) {
async subscribe(channel_id: string): Promise<ApiResponse> {
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { 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/subscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'EgIIAhgA'
});
return action;
}
/**
* Unsubscribes from a given channel.
* @param channel_id - The channel ID
*/
async unsubscribe(channel_id: string) {
async unsubscribe(channel_id: string): Promise<ApiResponse>{
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { 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'
});
return action;
}
/**
* Posts a comment on a given video.
* @param video_id - The video ID
* @param text - The comment text
*/
async comment(video_id: string, text: string) {
async comment(video_id: string, text: string): Promise<ApiResponse> {
throwIfMissing({ video_id, text });
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
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)
});
return action;
}
@@ -71,12 +137,11 @@ class InteractionManager {
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
throwIfMissing({ text, target_language });
const response = await await this.#actions.engage('comment/perform_comment_action', {
video_id: args.video_id,
comment_id: args.comment_id,
target_language: target_language,
comment_action: 'translate',
text
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 mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
@@ -92,10 +157,29 @@ class InteractionManager {
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
* @param channel_id - The channel ID.
* @param type - The notification type.
*/
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') {
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
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])
});
return action;
}
}

68
src/core/Kids.ts Normal file
View File

@@ -0,0 +1,68 @@
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<Search> {
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<VideoInfo> {
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<Channel> {
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<HomeFeed> {
const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' });
return new HomeFeed(this.#session.actions, response);
}
}
export default Kids;

View File

@@ -1,36 +1,36 @@
import Session from './Session';
import TrackInfo from '../parser/ytmusic/TrackInfo';
import Search from '../parser/ytmusic/Search';
import HomeFeed from '../parser/ytmusic/HomeFeed';
import Explore from '../parser/ytmusic/Explore';
import Library from '../parser/ytmusic/Library';
import Artist from '../parser/ytmusic/Artist';
import Album from '../parser/ytmusic/Album';
import Playlist from '../parser/ytmusic/Playlist';
import Recap from '../parser/ytmusic/Recap';
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 Tab from '../parser/classes/Tab';
import Tabbed from '../parser/classes/Tabbed';
import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults';
import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults';
import SectionList from '../parser/classes/SectionList';
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 Message from '../parser/classes/Message';
import MusicQueue from '../parser/classes/MusicQueue';
import PlaylistPanel from '../parser/classes/PlaylistPanel';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo';
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
import { observe } from '../parser/helpers.js';
import Proto from '../proto/index.js';
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils.js';
import { observe, ObservedArray, YTNode } from '../parser/helpers';
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
import type { ObservedArray, YTNode } from '../parser/helpers.js';
import type Actions from './Actions.js';
import type Session from './Session.js';
class Music {
#session;
#actions;
#session: Session;
#actions: Actions;
constructor(session: Session) {
this.#session = session;
@@ -39,7 +39,7 @@ class Music {
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - video id or a list item.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
@@ -51,7 +51,7 @@ class Music {
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
}
async #fetchInfoFromVideoId(video_id: string) {
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
const cpn = generateRandomString(16);
const initial_info = this.#actions.execute('/player', {
@@ -60,7 +60,7 @@ class Music {
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
@@ -74,7 +74,7 @@ class Music {
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) {
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
if (!list_item)
throw new InnertubeError('List item cannot be undefined');
@@ -83,17 +83,17 @@ class Music {
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.callTest(this.#actions, {
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player.sts
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = list_item.endpoint.callTest(this.#actions, {
const continuation = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
@@ -105,20 +105,38 @@ class Music {
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
} = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
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);
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
}
/**
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
return new HomeFeed(response, this.#actions);
}
@@ -126,20 +144,30 @@ class Music {
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_explore'
});
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
}
/**
* Retrieves the Library.
* Retrieves the library.
*/
getLibrary() {
return new Library(this.#actions);
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_library_landing'
});
return new Library(response, this.#actions);
}
/**
* Retrieves artist's info & content.
* @param artist_id - The artist id.
*/
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
@@ -147,12 +175,17 @@ 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.browse(artist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
@@ -160,12 +193,17 @@ 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.browse(album_id, { client: 'YTMUSIC' });
return new Album(response, this.#actions);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
return new Album(response);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
@@ -174,12 +212,18 @@ class Music {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: playlist_id
});
return new Playlist(response, this.#actions);
}
/**
* Retrieves up next.
* @param video_id - The video id.
* @param automix - Whether to enable automix.
*/
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
@@ -190,13 +234,9 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs.get({ title: 'Up next' });
const tab = tabs?.first();
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -214,16 +254,16 @@ class Music {
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
if (!page)
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel)?.[0];
return page.contents_memo.getType(PlaylistPanel).first();
}
return playlist_panel;
@@ -231,6 +271,7 @@ class Music {
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
@@ -241,29 +282,26 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs.get({ title: 'Related' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
}
/**
* Retrieves song lyrics.
* @param video_id - The video id.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
@@ -274,26 +312,23 @@ class Music {
parse: true
});
const tabs = data.contents.item()
.as(SingleColumnMusicWatchNextResults).contents.item()
.as(Tabbed).contents.item()
.as(WatchNextTabbedResults)
.tabs.array().as(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs.get({ title: 'Lyrics' });
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
const section_list = page.contents.item().as(SectionList).contents.array();
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}
@@ -311,17 +346,18 @@ class Music {
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string) {
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
const response = await this.#actions.execute('/music/get_search_suggestions', {
parse: true,
input: query,
client: 'YTMUSIC'
});
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
const search_suggestions_section = response.contents_memo?.getType(SearchSuggestionsSection)?.[0];
if (!search_suggestions_section.contents.is_array)
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
return search_suggestions_section?.contents.array();

View File

@@ -1,6 +1,6 @@
import Session from './Session';
import Constants from '../utils/Constants';
import { OAuthError, uuidv4 } from '../utils/Utils';
import Constants from '../utils/Constants.js';
import { OAuthError, Platform } from '../utils/Utils.js';
import type Session from './Session.js';
export interface Credentials {
/**
@@ -41,7 +41,7 @@ class OAuth {
/**
* Starts the auth flow in case no valid credentials are available.
*/
async init(credentials?: Credentials) {
async init(credentials?: Credentials): Promise<void> {
this.#credentials = credentials;
if (this.validateCredentials()) {
@@ -55,13 +55,13 @@ class OAuth {
}
}
async cacheCredentials() {
async cacheCredentials(): Promise<void> {
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(this.#credentials));
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
}
async #loadCachedCredentials() {
async #loadCachedCredentials(): Promise<boolean> {
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
if (!data) return false;
@@ -82,20 +82,20 @@ class OAuth {
return true;
}
async removeCache() {
async removeCache(): Promise<void> {
await this.#session.cache?.remove('youtubei_oauth_credentials');
}
/**
* Asks the server for a user code and verification URL.
*/
async #getUserCode() {
async #getUserCode(): Promise<void> {
this.#identity = await this.#getClientIdentity();
const data = {
client_id: this.#identity.client_id,
scope: Constants.OAUTH.SCOPE,
device_id: uuidv4(),
device_id: Platform.shim.uuidv4(),
model_name: Constants.OAUTH.MODEL_NAME
};
@@ -117,7 +117,7 @@ class OAuth {
/**
* Polls the authorization server until access is granted by the user.
*/
#startPolling(device_code: string) {
#startPolling(device_code: string): void {
const poller = setInterval(async () => {
const data = {
...this.#identity,
@@ -176,13 +176,13 @@ class OAuth {
/**
* Refresh access token if the same has expired.
*/
async refreshIfRequired() {
async refreshIfRequired(): Promise<void> {
if (this.has_access_token_expired) {
await this.#refreshAccessToken();
}
}
async #refreshAccessToken() {
async #refreshAccessToken(): Promise<void> {
if (!this.#credentials) return;
this.#identity = await this.#getClientIdentity();
@@ -215,7 +215,7 @@ class OAuth {
});
}
async revokeCredentials() {
async revokeCredentials(): Promise<Response | undefined> {
if (!this.#credentials) return;
await this.removeCache();
return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), {
@@ -226,7 +226,7 @@ class OAuth {
/**
* Retrieves client identity from YouTube TV.
*/
async #getClientIdentity() {
async #getClientIdentity(): Promise<{ [key: string]: string; }> {
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS });
const response_data = await response.text();
@@ -241,7 +241,6 @@ class OAuth {
.replace(/\n/g, '')
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
// TODO: check this.
const groups = client_identity?.groups;
if (!groups)
@@ -250,7 +249,7 @@ class OAuth {
return groups;
}
get credentials() {
get credentials(): Credentials | undefined {
return this.#credentials;
}

View File

@@ -1,12 +1,9 @@
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.js';
import { FetchFunction } from '../utils/HTTPClient';
import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils';
import Constants from '../utils/Constants.js';
import Constants from '../utils/Constants';
import UniversalCache from '../utils/Cache';
// See https://github.com/LuanRT/Jinter
import Jinter from 'jintr';
import { ICache } from '../types/Cache.js';
import { FetchFunction } from '../types/PlatformShim.js';
export default class Player {
#nsig_sc;
@@ -23,7 +20,7 @@ export default class Player {
this.#player_id = player_id;
}
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) {
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
const res = await fetch(url);
@@ -66,7 +63,7 @@ export default class Player {
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
}
decipher(url?: string, signature_cipher?: string, cipher?: string) {
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
url = url || signature_cipher || cipher;
if (!url)
@@ -75,13 +72,13 @@ export default class Player {
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
url_components.searchParams.set('ratebypass', 'yes');
if (signature_cipher || cipher) {
const sig_decipher = new Jinter(this.#sig_sc);
sig_decipher.scope.set('sig', args.get('s'));
const signature = Platform.shim.eval(this.#sig_sc, {
sig: args.get('s')
});
const signature = sig_decipher.interpret();
if (typeof signature !== 'string')
throw new PlayerError('Failed to decipher signature');
const sp = args.get('sp');
@@ -93,10 +90,12 @@ export default class Player {
const n = url_components.searchParams.get('n');
if (n) {
const nsig_decipher = new Jinter(this.#nsig_sc);
nsig_decipher.scope.set('nsig', n);
const nsig = Platform.shim.eval(this.#nsig_sc, {
nsig: n
});
const nsig = nsig_decipher.interpret();
if (typeof nsig !== 'string')
throw new PlayerError('Failed to decipher nsig');
if (nsig.startsWith('enhanced_except_')) {
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
@@ -108,7 +107,7 @@ export default class Player {
return url_components.toString();
}
static async fromCache(cache: UniversalCache, player_id: string) {
static async fromCache(cache: ICache, player_id: string): Promise<Player | null> {
const buffer = await cache.get(player_id);
if (!buffer)
@@ -134,13 +133,13 @@ export default class Player {
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
}
static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
static async fromSource(cache: ICache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise<Player> {
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
await player.cache(cache);
return player;
}
async cache(cache?: UniversalCache) {
async cache(cache?: ICache): Promise<void> {
if (!cache) return;
const encoder = new TextEncoder();
@@ -161,22 +160,22 @@ export default class Player {
await cache.set(this.#player_id, new Uint8Array(buffer));
}
static extractSigTimestamp(data: string) {
static extractSigTimestamp(data: string): number {
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
}
static extractSigSourceCode(data: string) {
static extractSigSourceCode(data: string): string {
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
const obj_name = calls?.split('.')?.[0]?.replace(';', '');
const functions = getStringBetweenStrings(data, `var ${obj_name}=`, '};');
const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
if (!functions || !calls)
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}=${functions}}${calls} return a.join("") } descramble_sig(sig);`;
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
}
static extractNSigSourceCode(data: string) {
static extractNSigSourceCode(data: string): string {
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
if (!sc)
@@ -185,23 +184,23 @@ export default class Player {
return sc;
}
get url() {
get url(): string {
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
get sts() {
get sts(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc() {
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc() {
get sig_sc(): string {
return this.#sig_sc;
}
static get LIBRARY_VERSION() {
static get LIBRARY_VERSION(): number {
return 2;
}
}

View File

@@ -1,11 +1,11 @@
import Playlist from '../parser/youtube/Playlist';
import Actions from './Actions';
import Feed from './Feed';
import type Feed from './Feed.js';
import type Actions from './Actions.js';
import Playlist from '../parser/youtube/Playlist.js';
import { InnertubeError, throwIfMissing } from '../utils/Utils';
import { InnertubeError, throwIfMissing } from '../utils/Utils.js';
class PlaylistManager {
#actions;
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
@@ -13,11 +13,20 @@ class PlaylistManager {
/**
* Creates a playlist.
* @param title - The title of the playlist.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async create(title: string, video_ids: string[]) {
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id?: string; data: any }> {
throwIfMissing({ title, video_ids });
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
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
});
return {
success: response.success,
@@ -29,10 +38,14 @@ class PlaylistManager {
/**
* Deletes a given playlist.
* @param playlist_id - The playlist ID.
*/
async delete(playlist_id: string) {
async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> {
throwIfMissing({ playlist_id });
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 });
return {
@@ -45,10 +58,15 @@ class PlaylistManager {
/**
* Adds videos to a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async addVideos(playlist_id: string, video_ids: string[]) {
async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
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) => ({
@@ -66,11 +84,20 @@ class PlaylistManager {
/**
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
*/
async removeVideos(playlist_id: string, video_ids: string[]) {
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
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 playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -115,11 +142,21 @@ class PlaylistManager {
/**
* Moves a video to a new position within a given playlist.
* @param playlist_id - The playlist ID.
* @param moved_video_id - The video ID to move.
* @param predecessor_video_id - The video ID to move the moved video before.
*/
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) {
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
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 playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -157,7 +194,10 @@ class PlaylistManager {
movedSetVideoIdPredecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
return {
playlist_id,

View File

@@ -1,25 +1,34 @@
import Player from './Player';
import Actions from './Actions';
import Constants from '../utils/Constants';
import UniversalCache from '../utils/Cache';
import EventEmitterLike from '../utils/EventEmitterLike';
import Constants, { CLIENTS } from '../utils/Constants.js';
import EventEmitterLike from '../utils/EventEmitterLike.js';
import Actions from './Actions.js';
import Player from './Player.js';
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
import { DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
import HTTPClient from '../utils/HTTPClient.js';
import { Platform, DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils.js';
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.js';
import Proto from '../proto/index.js';
import { ICache } from '../types/Cache.js';
import { FetchFunction } from '../types/PlatformShim.js';
export enum ClientType {
WEB = 'WEB',
KIDS = 'WEB_KIDS',
MUSIC = 'WEB_REMIX',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC'
ANDROID_MUSIC = 'ANDROID_MUSIC',
ANDROID_CREATOR = 'ANDROID_CREATOR',
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
client: {
hl: string;
gl: string;
remoteHost: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
userAgent: string;
clientName: string;
@@ -32,15 +41,25 @@ export interface Context {
clientFormFactor: string;
userInterfaceTheme: string;
timeZone: string;
browserName: string;
browserVersion: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
};
contentSettings: {
corpusPreference: string;
kidsNoSearchMode: string;
};
};
};
user: {
lockedSafetyMode: false;
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
};
thirdParty?: {
embedUrl: string;
@@ -51,30 +70,83 @@ export interface Context {
}
export interface SessionOptions {
/**
* Language.
*/
lang?: string;
/**
* Geolocation.
*/
location?: string;
/**
* The account index to use. This is useful if you have multiple accounts logged in.
* **NOTE:**
* Only works if you are signed in with cookies.
*/
account_index?: number;
/**
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
* **NOTE:** Deciphering formats is not possible without the JS player.
*/
retrieve_player?: boolean;
/**
* Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
*/
enable_safety_mode?: boolean;
/**
* Specifies whether to generate the session data locally or retrieve it from YouTube.
* This can be useful if you need more performance.
*/
generate_session_locally?: boolean;
/**
* Platform to use for the session.
*/
device_category?: DeviceCategory;
/**
* InnerTube client type.
*/
client_type?: ClientType;
/**
* The time zone.
*/
timezone?: string;
cache?: UniversalCache;
/**
* Used to cache the deciphering functions from the JS player.
*/
cache?: ICache;
/**
* YouTube cookies.
*/
cookie?: string;
/**
* Fetch function to use.
*/
fetch?: FetchFunction;
}
export interface SessionData {
context: Context;
api_key: string;
api_version: string;
}
export default class Session extends EventEmitterLike {
#api_version;
#key;
#context;
#player;
#api_version: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
oauth;
http;
logged_in;
actions;
cache;
oauth: OAuth;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: ICache;
constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) {
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
super();
this.#context = context;
this.#account_index = account_index;
this.#key = api_key;
this.#api_version = api_version;
this.#player = player;
@@ -103,32 +175,69 @@ export default class Session extends EventEmitterLike {
}
static async create(options: SessionOptions = {}) {
const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch);
return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch, options.cache);
const { context, api_key, api_version, account_index } = await Session.getSessionData(
options.lang,
options.location,
options.account_index,
options.enable_safety_mode,
options.generate_session_locally,
options.device_category,
options.client_type,
options.timezone,
options.fetch
);
return new Session(
context, api_key, api_version, account_index,
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch),
options.cookie, options.fetch, options.cache
);
}
static async getSessionData(
lang = 'en-US',
lang = '',
location = '',
account_index = 0,
enable_safety_mode = false,
generate_session_locally = false,
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = globalThis.fetch
fetch: FetchFunction = Platform.shim.fetch
) {
let session_data: SessionData;
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode });
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode }, fetch);
}
return { ...session_data, account_index };
}
static async #retrieveSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: string;
client_name: string;
enable_safety_mode: boolean;
}, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
const res = await fetch(url, {
headers: {
'accept-language': lang,
'accept-language': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${tz.replace('/', '.')}`
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')}`
}
});
if (!res.ok) {
throw new SessionError(`Failed to get session data: ${res.status}`);
}
if (!res.ok)
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
const text = await res.text();
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
@@ -142,26 +251,31 @@ export default class Session extends EventEmitterLike {
const context: Context = {
client: {
hl: device_info[0],
gl: device_info[2],
gl: options.location || device_info[2],
remoteHost: device_info[3],
visitorData: data[3],
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: device_info[13],
userAgent: device_info[14],
clientName: client_name,
clientName: options.client_name,
clientVersion: device_info[16],
osName: device_info[17],
osVersion: device_info[18],
platform: device_category.toUpperCase(),
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: device_info[79],
timeZone: device_info[79] || options.time_zone,
browserName: device_info[86],
browserVersion: device_info[87],
originalUrl: Constants.URLS.API.BASE,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: device_info[11],
deviceModel: device_info[12],
utcOffsetMinutes: new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
},
request: {
@@ -172,6 +286,52 @@ export default class Session extends EventEmitterLike {
return { context, api_key, api_version };
}
static #generateSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean
}): SessionData {
const id = generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const context: Context = {
client: {
hl: options.lang || 'en',
gl: options.location || 'US',
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: Proto.encodeVisitorData(id, timestamp),
userAgent: getRandomUserAgent('desktop'),
clientName: options.client_name,
clientVersion: CLIENTS.WEB.VERSION,
osName: 'Windows',
osVersion: '10.0',
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: options.time_zone,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: '',
deviceModel: '',
utcOffsetMinutes: new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
},
request: {
useSsl: true
}
};
return { context, api_key: CLIENTS.WEB.API_KEY, api_version: CLIENTS.WEB.API_VERSION };
}
async signIn(credentials?: Credentials): Promise<void> {
return new Promise(async (resolve, reject) => {
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
@@ -203,9 +363,12 @@ export default class Session extends EventEmitterLike {
});
}
async signOut() {
/**
* Signs out of the current account and revokes the credentials.
*/
async signOut(): Promise<Response | undefined> {
if (!this.logged_in)
throw new InnertubeError('You are not signed in');
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.oauth.revokeCredentials();
this.logged_in = false;
@@ -213,31 +376,41 @@ export default class Session extends EventEmitterLike {
return response;
}
get key() {
/**
* InnerTube API key.
*/
get key(): string {
return this.#key;
}
get api_version() {
/**
* InnerTube API version.
*/
get api_version(): string {
return this.#api_version;
}
get client_version() {
get client_version(): string {
return this.#context.client.clientVersion;
}
get client_name() {
get client_name(): string {
return this.#context.client.clientName;
}
get context() {
get account_index(): number {
return this.#account_index;
}
get context(): Context {
return this.#context;
}
get player() {
get player(): Player | undefined {
return this.#player;
}
get lang() {
get lang(): string {
return this.#context.client.hl;
}
}

View File

@@ -1,8 +1,9 @@
import Proto from '../proto';
import Session from './Session';
import { AxioslikeResponse } from './Actions';
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
import { Constants } from '../utils';
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';
interface UploadResult {
status: string;
@@ -36,7 +37,7 @@ export interface UploadedVideoMetadata {
}
class Studio {
#session;
#session: Session;
constructor(session: Session) {
this.#session = session;
@@ -50,7 +51,10 @@ class Studio {
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
if (!video_id || !buffer)
throw new MissingParamError('One or more parameters are missing.');
@@ -73,12 +77,15 @@ class Studio {
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* licence: 'creative_commons'
* license: 'creative_commons'
* // ...
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: VideoMetadata) {
async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
@@ -97,7 +104,10 @@ class Studio {
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<AxioslikeResponse> {
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const initial_data = await this.#getInitialUploadData();
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);
@@ -110,12 +120,12 @@ class Studio {
}
async #getInitialUploadData(): Promise<InitialUploadData> {
const frontend_upload_id = `innertube_android:${uuidv4()}:0:v=3,api=1,cf=3`;
const frontend_upload_id = `innertube_android:${Platform.shim.uuidv4()}:0:v=3,api=1,cf=3`;
const payload = {
frontendUploadId: frontend_upload_id,
deviceDisplayName: 'Pixel 6 Pro',
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${uuidv4()}`,
fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${Platform.shim.uuidv4()}`,
mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
transcodeResult: 'DISABLED',
connectionType: 'WIFI'

View File

@@ -1,24 +1,28 @@
import Tab from '../parser/classes/Tab';
import { InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import Feed from './Feed';
import Tab from '../parser/classes/Tab.js';
import Feed from './Feed.js';
import { InnertubeError } from '../utils/Utils.js';
class TabbedFeed extends Feed {
#tabs;
#actions;
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';
constructor(actions: Actions, data: any, already_parsed = false) {
class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
#tabs?: ObservedArray<Tab>;
#actions: Actions;
constructor(actions: Actions, data: ApiResponse | IParsedResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.#actions = actions;
this.#tabs = this.page.contents_memo.getType(Tab);
this.#tabs = this.page.contents_memo?.getType(Tab);
}
get tabs() {
return this.#tabs.map((tab) => tab.title.toString());
get tabs(): string[] {
return this.#tabs?.map((tab) => tab.title.toString()) ?? [];
}
async getTab(title: string) {
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
async getTabByName(title: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
throw new InnertubeError(`Tab "${title}" not found`);
@@ -28,14 +32,29 @@ class TabbedFeed extends Feed {
const response = await tab.endpoint.call(this.#actions);
if (!response)
throw new InnertubeError('Failed to call endpoint');
return new TabbedFeed(this.#actions, response.data, false);
return new TabbedFeed<T>(this.#actions, response, false);
}
get title() {
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
async getTabByURL(url: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
if (!tab)
throw new InnertubeError(`Tab "${url}" not found`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed<T>(this.#actions, response, false);
}
hasTabWithURL(url: string): boolean {
return this.#tabs?.some((tab) => tab.endpoint.metadata.url?.split('/').pop() === url) ?? false;
}
get title(): string | undefined {
return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
}
}

38
src/core/index.ts Normal file
View File

@@ -0,0 +1,38 @@
export { default as AccountManager } from './AccountManager.js';
export * from './AccountManager.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 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';

View File

@@ -1,6 +1,19 @@
# Parser
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts#L69) how API calls are made and handled.
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data.
Structure:
* [`/classes`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes) - InnerTube nodes.
* [`/types`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/types) - General response types.
* [`/youtube`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube) - Contains the logic for parsing YouTube responses.
* [`/ytmusic`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytmusic) - Contains the logic for parsing YouTube Music responses.
* [`/ytkids`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytkids) - Contains the logic for parsing YouTube Kids responses.
* [`helpers.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/helpers.ts) - Helper functions/classes for the parser.
* [`parser.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/parser.ts) - The core of the parser.
* [`map.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/map.ts) - A list of all InnerTube nodes, it is used to determine which node to use for a given renderer. Note that this file is auto-generated and should not be edited manually.
## Table of Contents
<ol>
<li>
@@ -328,4 +341,4 @@ And what we get after parsing it:
```
</p>
</details>
</details>

View File

@@ -1,6 +1,6 @@
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
class AccountChannel extends YTNode {
static type = 'AccountChannel';

View File

@@ -1,11 +1,11 @@
import Parser from '..';
import Parser from '../index.js';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import AccountItemSectionHeader from './AccountItemSectionHeader';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import AccountItemSectionHeader from './AccountItemSectionHeader.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class AccountItem {
static type = 'AccountItem';

View File

@@ -1,5 +1,5 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class AccountItemSectionHeader extends YTNode {
static type = 'AccountItemSectionHeader';

View File

@@ -1,8 +1,8 @@
import Parser from '..';
import AccountChannel from './AccountChannel';
import AccountItemSection from './AccountItemSection';
import Parser from '../index.js';
import AccountChannel from './AccountChannel.js';
import AccountItemSection from './AccountItemSection.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class AccountSectionList extends YTNode {
static type = 'AccountSectionList';

View File

@@ -0,0 +1,17 @@
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class Alert extends YTNode {
static type = 'Alert';
text: Text;
alert_type: string;
constructor(data: any) {
super();
this.text = new Text(data.text);
this.alert_type = data.type;
}
}
export default Alert;

View File

@@ -1,4 +1,4 @@
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class AudioOnlyPlayability extends YTNode {
static type = 'AudioOnlyPlayability';

View File

@@ -1,5 +1,5 @@
import { YTNode } from '../helpers';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
class AutomixPreviewVideo extends YTNode {
static type = 'AutomixPreviewVideo';

View File

@@ -1,14 +1,17 @@
import Thumbnail from './misc/Thumbnail';
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
class BackstageImage extends YTNode {
static type = 'BackstageImage';
image: Thumbnail[];
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.endpoint = new NavigationEndpoint(data.command);
}
}

View File

@@ -1,9 +1,11 @@
import Parser from '../index';
import Author from './misc/Author';
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import Parser from '../index.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import type CommentActionButtons from './comments/CommentActionButtons.js';
import type Menu from './menus/Menu.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class BackstagePost extends YTNode {
static type = 'BackstagePost';
@@ -12,19 +14,18 @@ class BackstagePost extends YTNode {
author: Author;
content: Text;
published: Text;
poll_status: string;
vote_status: string;
likes: Text;
menu;
actions;
poll_status?: string;
vote_status?: string;
vote_count?: Text;
menu?: Menu | null;
action_buttons;
vote_button;
surface: string;
endpoint: NavigationEndpoint;
endpoint?: NavigationEndpoint;
attachment;
constructor(data: any) {
super();
this.id = data.postId;
this.author = new Author({
@@ -34,15 +35,40 @@ class BackstagePost extends YTNode {
this.content = new Text(data.contentText);
this.published = new Text(data.publishedTimeText);
this.poll_status = data.pollStatus;
this.vote_status = data.voteStatus;
this.likes = new Text(data.voteCount);
this.menu = Parser.parse(data.actionMenu) || null;
this.actions = Parser.parse(data.actionButtons);
this.vote_button = Parser.parse(data.voteButton);
if (data.pollStatus) {
this.poll_status = data.pollStatus;
}
if (data.voteStatus) {
this.vote_status = data.voteStatus;
}
if (data.voteCount) {
this.vote_count = new Text(data.voteCount);
}
if (data.actionMenu) {
this.menu = Parser.parseItem<Menu>(data.actionMenu);
}
if (data.actionButtons) {
this.action_buttons = Parser.parseItem<CommentActionButtons>(data.actionButtons);
}
if (data.voteButton) {
this.vote_button = Parser.parseItem(data.voteButton);
}
if (data.navigationEndpoint) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
if (data.backstageAttachment) {
this.attachment = Parser.parseItem(data.backstageAttachment);
}
this.surface = data.surface;
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.attachment = Parser.parse(data.backstageAttachment) || null;
}
}

View File

@@ -1,5 +1,5 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import { YTNode } from '../helpers.js';
class BackstagePostThread extends YTNode {
static type = 'BackstagePostThread';
@@ -8,7 +8,7 @@ class BackstagePostThread extends YTNode {
constructor(data: any) {
super();
this.post = Parser.parse(data.post);
this.post = Parser.parseItem(data.post);
}
}

View File

@@ -1,5 +1,5 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import { YTNode } from '../helpers.js';
class BrowseFeedActions extends YTNode {
static type = 'BrowseFeedActions';

View File

@@ -1,6 +1,6 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
class BrowserMediaSession extends YTNode {
static type = 'BrowserMediaSession';

View File

@@ -1,22 +1,26 @@
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class Button extends YTNode {
static type = 'Button';
text: string;
text?: string;
label;
tooltip;
icon_type;
label?: string;
tooltip?: string;
icon_type?: string;
is_disabled?: boolean;
endpoint: NavigationEndpoint;
constructor(data: any) {
super();
this.text = new Text(data.text).toString();
if (data.text) {
this.text = new Text(data.text).toString();
}
if (data.accessibility?.label) {
this.label = data.accessibility?.label;
@@ -30,6 +34,10 @@ class Button extends YTNode {
this.icon_type = data.icon?.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}
}

View File

@@ -1,20 +1,28 @@
import Parser from '../index';
import Author from './misc/Author';
import Thumbnail from './misc/Thumbnail';
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import type Button from './Button.js';
import type ChannelHeaderLinks from './ChannelHeaderLinks.js';
import type SubscribeButton from './SubscribeButton.js';
import { YTNode } from '../helpers.js';
class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author;
banner;
tv_banner;
mobile_banner;
subscribers;
sponsor_button;
subscribe_button;
header_links;
author: Author;
banner?: Thumbnail[];
tv_banner?: Thumbnail[];
mobile_banner?: Thumbnail[];
subscribers?: Text;
videos_count?: Text;
sponsor_button?: Button | null;
subscribe_button?: SubscribeButton | null;
header_links?: ChannelHeaderLinks | null;
channel_handle?: Text;
channel_id?: string;
constructor(data: any) {
super();
@@ -23,13 +31,45 @@ class C4TabbedHeader extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
this.banner = data.banner ? Thumbnail.fromResponse(data.banner) : [];
this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : [];
this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : [];
this.subscribers = new Text(data.subscriberCountText);
this.sponsor_button = data.sponsorButton ? Parser.parseItem(data.sponsorButton) : undefined;
this.subscribe_button = data.subscribeButton ? Parser.parseItem(data.subscribeButton) : undefined;
this.header_links = data.headerLinks ? Parser.parse(data.headerLinks) : undefined;
if (data.banner) {
this.banner = Thumbnail.fromResponse(data.banner);
}
if (data.tv_banner) {
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
}
if (data.mobile_banner) {
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
}
if (data.subscriberCountText) {
this.subscribers = new Text(data.subscriberCountText);
}
if (data.videosCountText) {
this.videos_count = new Text(data.videosCountText);
}
if (data.sponsorButton) {
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
}
if (data.subscribeButton) {
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
}
if (data.headerLinks) {
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
}
if (data.channelHandleText) {
this.channel_handle = new Text(data.channelHandleText);
}
if (data.channelId) {
this.channel_id = data.channelId;
}
}
}

View File

@@ -1,5 +1,5 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class CallToActionButton extends YTNode {
static type = 'CallToActionButton';

View File

@@ -1,5 +1,5 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import { YTNode } from '../helpers.js';
class Card extends YTNode {
static type = 'Card';

View File

@@ -1,6 +1,6 @@
import Parser from '../index';
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class CardCollection extends YTNode {
static type = 'CardCollection';

View File

@@ -0,0 +1,15 @@
import Parser from '../index.js';
import { YTNode } from '../helpers.js';
class CarouselHeader extends YTNode {
static type = 'CarouselHeader';
contents: YTNode[];
constructor(data: any) {
super();
this.contents = Parser.parseArray(data.contents);
}
}
export default CarouselHeader;

View File

@@ -0,0 +1,25 @@
import Parser from '../index.js';
import { YTNode } from '../helpers.js';
import Thumbnail from './misc/Thumbnail.js';
class CarouselItem extends YTNode {
static type = 'CarouselItem';
items: YTNode[];
background_color: string;
layout_style: string;
pagination_thumbnails: Thumbnail[];
paginator_alignment: string;
constructor (data: any) {
super();
this.items = Parser.parseArray(data.carouselItems);
this.background_color = data.backgroundColor;
this.layout_style = data.layoutStyle;
this.pagination_thumbnails = Thumbnail.fromResponse(data.paginationThumbnails);
this.paginator_alignment = data.paginatorAlignment;
}
}
export default CarouselItem;

View File

@@ -1,7 +1,12 @@
import Author from './misc/Author';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import Text from './misc/Text.js';
import Author from './misc/Author.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import type SubscribeButton from './SubscribeButton.js';
import { YTNode } from '../helpers.js';
class Channel extends YTNode {
static type = 'Channel';
@@ -10,7 +15,10 @@ class Channel extends YTNode {
author: Author;
subscribers: Text;
videos: Text;
long_byline: Text;
short_byline: Text;
endpoint: NavigationEndpoint;
subscribe_button: SubscribeButton | null;
description_snippet: Text;
constructor(data: any) {
@@ -22,9 +30,13 @@ class Channel extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.ownerBadges, data.thumbnail);
// TODO: subscriberCountText is now the channel's handle and videoCountText is the subscriber count. Why haven't they renamed the properties?
this.subscribers = new Text(data.subscriberCountText);
this.videos = new Text(data.videoCountText);
this.long_byline = new Text(data.longBylineText);
this.short_byline = new Text(data.shortBylineText);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.description_snippet = new Text(data.descriptionSnippet);
}
}

View File

@@ -1,8 +1,12 @@
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import Parser from '../index';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import type Button from './Button.js';
import { YTNode } from '../helpers.js';
class ChannelAboutFullMetadata extends YTNode {
static type = 'ChannelAboutFullMetadata';
@@ -11,13 +15,20 @@ class ChannelAboutFullMetadata extends YTNode {
name: Text;
avatar: Thumbnail[];
canonical_channel_url: string;
primary_links: {
endpoint: NavigationEndpoint;
icon: Thumbnail[];
title: Text;
}[];
views: Text;
joined: Text;
description: Text;
email_reveal: NavigationEndpoint;
can_reveal_email: boolean;
country: Text;
buttons;
buttons: Button[];
constructor(data: any) {
super();
@@ -25,13 +36,20 @@ class ChannelAboutFullMetadata extends YTNode {
this.name = new Text(data.title);
this.avatar = Thumbnail.fromResponse(data.avatar);
this.canonical_channel_url = data.canonicalChannelUrl;
this.primary_links = data.primaryLinks?.map((link: any) => ({
endpoint: new NavigationEndpoint(link.navigationEndpoint),
icon: Thumbnail.fromResponse(link.icon),
title: new Text(link.title)
})) ?? [];
this.views = new Text(data.viewCountText);
this.joined = new Text(data.joinedDateText);
this.description = new Text(data.description);
this.email_reveal = new NavigationEndpoint(data.onBusinessEmailRevealClickCommand);
this.can_reveal_email = !data.signInForBusinessEmail;
this.country = new Text(data.country);
this.buttons = Parser.parse(data.actionButtons);
this.buttons = Parser.parseArray<Button>(data.actionButtons);
}
}

View File

@@ -0,0 +1,30 @@
import { Parser } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
class ChannelAgeGate extends YTNode {
static type = 'ChannelAgeGate';
channel_title: string;
avatar: Thumbnail[];
header: Text;
main_text: Text;
sign_in_button: Button | null;
secondary_text: Text;
constructor(data: RawNode) {
super();
this.channel_title = data.channelTitle;
this.avatar = Thumbnail.fromResponse(data.avatar);
this.header = new Text(data.header);
this.main_text = new Text(data.mainText);
this.sign_in_button = Parser.parseItem<Button>(data.signInButton, Button);
this.secondary_text = new Text(data.secondaryText);
}
}
export default ChannelAgeGate;

View File

@@ -1,6 +1,6 @@
import Parser from '../index';
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Parser from '../index.js';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class ChannelFeaturedContent extends YTNode {
static type = 'ChannelFeaturedContent';

View File

@@ -1,7 +1,7 @@
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import { YTNode } from '../helpers';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
class HeaderLink {
endpoint: NavigationEndpoint;

View File

@@ -1,5 +1,5 @@
import Thumbnail from './misc/Thumbnail';
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
class ChannelMetadata extends YTNode {
static type = 'ChannelMetadata';

View File

@@ -1,5 +1,5 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class ChannelMobileHeader extends YTNode {
static type = 'ChannelMobileHeader';

View File

@@ -1,8 +1,8 @@
import Text from './misc/Text';
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class ChannelOptions extends YTNode {
static type = 'ChannelOptions';

View File

@@ -0,0 +1,27 @@
import Parser from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
class ChannelSubMenu extends YTNode {
static type = 'ChannelSubMenu';
content_type_sub_menu_items: {
endpoint: NavigationEndpoint;
selected: boolean;
title: string;
}[];
sort_setting;
constructor(data: any) {
super();
this.content_type_sub_menu_items = data.contentTypeSubMenuItems.map((item: any) => ({
endpoint: new NavigationEndpoint(item.navigationEndpoint || item.endpoint),
selected: item.selected,
title: item.title
}));
this.sort_setting = Parser.parseItem(data.sortSetting);
}
}
export default ChannelSubMenu;

View File

@@ -1,6 +1,6 @@
import Thumbnail from './misc/Thumbnail';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
class ChannelThumbnailWithLink extends YTNode {
static type = 'ChannelThumbnailWithLink';

View File

@@ -1,5 +1,5 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
class ChannelVideoPlayer extends YTNode {
static type = 'ChannelVideoPlayer';

View File

@@ -0,0 +1,21 @@
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
class Chapter extends YTNode {
static type = 'Chapter';
title: Text;
time_range_start_millis: number;
thumbnail: Thumbnail[];
constructor(data: any) {
super();
this.title = new Text(data.title);
this.time_range_start_millis = data.timeRangeStartMillis;
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}
export default Chapter;

View File

@@ -1,8 +1,8 @@
import NavigationEndpoint from './NavigationEndpoint';
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import { timeToSeconds } from '../../utils/Utils';
import { YTNode } from '../helpers';
import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode } from '../helpers.js';
class ChildVideo extends YTNode {
static type = 'ChildVideo';

View File

@@ -1,8 +1,8 @@
import Parser from '../index';
import Button from './Button';
import ChipCloudChip from './ChipCloudChip';
import Parser from '../index.js';
import Button from './Button.js';
import ChipCloudChip from './ChipCloudChip.js';
import { YTNode } from '../helpers';
import { YTNode } from '../helpers.js';
class ChipCloud extends YTNode {
static type = 'ChipCloud';

View File

@@ -1,6 +1,6 @@
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
class ChipCloudChip extends YTNode {
static type = 'ChipCloudChip';

Some files were not shown because too many files have changed in this diff Show More