Compare commits

..

632 Commits

Author SHA1 Message Date
github-actions[bot]
2b29244b41 chore(main): release 3.3.0 (#343)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-09 01:24:26 -03:00
LuanRT
f9754f5ac6 fix(ytmusic): use static visitor id to avoid empty API responses
Fixes #279
2023-03-09 01:21:13 -03:00
LuanRT
b2253df802 feat(parser): add ConversationBar node 2023-03-08 18:09:21 -03:00
LuanRT
f3517708ff fix(MultiMarkersPlayerBar): avoid observing undefined objects 2023-03-08 17:43:20 -03:00
Patrick Kan
0d35fe0ca5 feat(VideoInfo): support get by endpoint + more info (#342)
* feat(VideoInfo): get by endpoint + more info

* chore: fix param description for `getInfo()`
2023-03-08 16:42:41 -03:00
LuanRT
3e3dc351bb fix(SharedPost): import Menu node directly (oops) 2023-03-08 11:29:39 -03:00
github-actions[bot]
197bb759cd chore(main): release 3.2.0 (#334)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-08 07:20:21 -03:00
LuanRT
c76b24b3f4 chore(parser): import YTNodes directly to reduce web bundle size 2023-03-08 07:18:01 -03:00
absidue
574b67a1f7 feat: Add support for descriptive audio tracks (#338) 2023-03-08 05:36:01 -03:00
LuanRT
9b2738f128 fix(SegmentedLikeDislikeButton): like/dislike buttons can also be a simple Button 2023-03-07 05:43:32 -03:00
LuanRT
95f1d4077f fix(YouTube): fix warnings when retrieving members-only content (#341) 2023-03-07 05:15:46 -03:00
LuanRT
a511608f18 feat(YouTube/Search): add SearchSubMenu node (#340) 2023-03-07 04:17:58 -03:00
LuanRT
cf8a33c79f fix(ytmusic): export search filters type 2023-03-07 03:02:44 -03:00
Chinmay Kumar
cfc1a183e0 refactor(parser): type YTNodes' data arg as RawNode (wip) (#339)
* replaced YTNode's data arg as RawNode

* updated documentation

* removed unused import

---- Note that there are still many nodes that need to be updated, hence the WIP status.
2023-03-07 02:02:07 -03:00
Patrick Kan
95033e723e feat(parser): add banner to PlaylistHeader (#337) 2023-03-05 22:44:09 -03:00
Patrick Kan
2cc7b8bcd6 feat(yt): add getGuide() (#335)
* feat(yt): add `getGuide()`

* chore: lint

* fix(Guide): wrong prop

* fix(Guide): include subscription section

* fix(Guide): wrong import

* feat(Guide): add `page`
2023-03-04 06:23:17 -03:00
LuanRT
2d774e26aa feat: export FormatUtils' types 2023-03-04 04:06:19 -03:00
Nico K
214aa147ce feat(VideoInfo): add game_info and category (#333) 2023-03-03 03:47:20 -03:00
Daniel Wykerd
ce53ac1843 feat(parser): SharedPost (#332)
Add support for SharedPost in community tab.
Related to issue #331
2023-03-03 03:41:04 -03:00
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
LuanRT
dc14d3785f chore(release): v2.3.2 2022-10-13 16:58:19 -03:00
LuanRT
088f909515 chore: update proto 2022-10-13 16:52:19 -03:00
LuanRT
2a78d77aa3 refactor: get visitor data from the API [skip ci] 2022-10-13 16:39:56 -03:00
LuanRT
1b2862c00f refactor: improve live chat polling (#220)
* dev: add RemoveChatItemByAuthorAction renderer parser

* dev: improve live chat polling
2022-10-12 16:16:07 -03:00
LuanRT
477c030084 feat(studio): add support for updating video metadata (#219)
* dev: update proto

* dev: add `Studio#updateVideoMetadata`

* feat: add `category` option

* chore(studio): update API ref
2022-10-12 16:08:53 -03:00
Émilien Devos
19d579df13 fix: wrong element name (#217) 2022-10-11 05:03:21 -03:00
LuanRT
5313c57783 chore(docs): fix typos [skip ci] 2022-10-06 05:24:09 -03:00
LuanRT
190f7681be chore: update tests 2022-10-06 05:20:24 -03:00
LuanRT
6e027bcc85 docs(livechat): update API ref 2022-10-06 04:44:49 -03:00
LuanRT
6b531dd0ea chore: lint 2022-10-06 04:38:28 -03:00
LuanRT
92f24076db docs(ytmusic): add library class docs 2022-10-06 04:36:17 -03:00
Akazawa Daisuke
a9eba7ca62 feat: add RemoveChatItemAction and LiveChatTickerStickerItem (#214) 2022-10-03 03:09:40 -03:00
Akazawa Daisuke
2f56c15ecc feat(LiveChat): add support for moderation & more (#202)
* Live Chat - Implement moderation

* Live Chat - Implement class ItemMenu

* fix moderation method

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2022-10-02 02:00:24 -03:00
LuanRT
95e0479745 docs: add parser ytnode instructions & other minor changes (#206)
* docs: add instructions on implementing ytnodes

* docs(parser): fix grammar & other minor improvements

* docs: update guidelines

* chore: update parser warning messages
2022-09-28 03:08:51 -03:00
LuanRT
556c7cd6e8 docs(parser): rephrase validTypes description [skip ci] 2022-09-23 03:38:11 -03:00
LuanRT
a4a88419ef docs(parser): escape | separators [skip ci] 2022-09-23 03:34:53 -03:00
LuanRT
aefecd061e chore(release): v2.2.3 2022-09-23 03:19:54 -03:00
LuanRT
7485726f1e refactor: fix a few parsing inconsistencies 2022-09-23 03:06:21 -03:00
LuanRT
9e703abe3a chore(deps): bump jintr to 0.3.1 2022-09-22 18:44:16 -03:00
LuanRT
affbe84284 fix: include thirdParty prop for requests using TV_EMBEDDED (#198)
* dev: update `Context` interface

* dev: include `thirdParty` prop in requests using `TV_EMBEDDED`
2022-09-18 16:58:51 -03:00
Daniel Wykerd
fcbdae3e34 fix: browser example (#197) 2022-09-18 12:46:19 -03:00
LuanRT
059c858021 chore(docs): add a note about streaming data [skip ci] 2022-09-17 21:29:33 -03:00
LuanRT
4ecd3360e0 chore(release): v2.2.1 2022-09-17 20:47:55 -03:00
LuanRT
08e9527931 chore: update proto [skip ci] 2022-09-17 20:07:23 -03:00
LuanRT
a9f03a1523 fix: like/dislike methods not working correctly 2022-09-17 19:49:05 -03:00
LuanRT
c8980c7985 chore(docs): fix typo 2022-09-17 19:28:46 -03:00
LuanRT
2e5688f235 feat: add TVHTML5_SIMPLY_EMBEDDED_PLAYER client (#193)
* feat: add `TV_EMBEDDED` client

See #191, this should help bypassing some age restricted videos.

* dev(VideoInfo): update format options interface

* dev: set `clientScreen` to `EMBED`

* dev: update API ref

* dev: update `Context` interface
2022-09-17 19:15:20 -03:00
LuanRT
dcf2b720a0 fix: minor parsing issues and other improvements (#194)
* feat: add `ConfirmDialog`

This usually appears in the `playability_status` object.

* fix(PlayerErrorMessage): check if `iconType` exists before parsing

* chore(parser): fix a few inconsistencies

* feat(ytmusic): add `MetadataScreen`

TODO: Check TrackInfo, YouTube Music is probably getting some minor UI updates.
2022-09-17 19:14:46 -03:00
SALVADOR, 1 M. LIGAYAO
a90f5eb853 fix: add missing import in index.ts (#188)
* added missing import in index.ts

* commit changes suggested by LuanRT

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

* removed extra require

Co-authored-by: Salvador Ligayao <futuremr.ligayao03@gmail.com>
Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2022-09-17 01:30:35 -03:00
Supertiger
c6482e07b9 docs: fix a small typo in api/session.md (#189)
It says "key" twice, someone forgot to rename one of them to "api_version" :)
2022-09-16 12:21:57 -03:00
LuanRT
2de77c8f2c fix: make cookie auth possible again
See #105
2022-09-14 14:52:10 -03:00
LuanRT
2aaa209906 chore(docs): fix typo [skip ci] 2022-09-13 03:03:22 -03:00
LuanRT
ab028ba1ec chore(package) release v2.1.0 2022-09-13 02:40:31 -03:00
LuanRT
f2f48af1bc feat(Music): add automix support and other minor improvements (#184)
* dev(NavigationEndpoint): add `/player` endpoint

* dev: add AudioOnlyPlayability, BrowserMediaSession and MusicDownloadStateBadge

* dev: allow endpoints to be overridden

* dev: minor parser changes

* dev(TrackInfo): add `<info>#getTab(title?)`

* dev: allow `Music#getInfo()` to accept list items

* dev: revert a few changes, I probably overcomplicated this.

* dev: add tests

* dev: add `TrackInfo#getUpNext()`, `TrackInfo#getRelated()` and `TrackInfo#getLyrics()`

* docs: update API ref

* fix(docs): formatting inconsistencies
2022-09-13 02:26:13 -03:00
LuanRT
3a7da21fd1 fix: improve sig extraction (#183)
* dev: improve sig decipher code extraction

* chore(deps): update Jinter to 0.2.0
2022-09-13 01:36:27 -03:00
LuanRT
89794d65da fix: likes not being parsed correctly 2022-09-11 22:44:27 -03:00
LuanRT
91847ae3cc feat(LiveChat): add SegmentedLikeDislikeButton and LiveChatDialog (#181)
* feat: add `LiveChatDialog`

* feat: add `SegmentedLikeDislikeButton`
2022-09-10 14:54:13 -03:00
LuanRT
eb44b71939 feat: add CollaboratorInfoCardContent renderer parser (#180) 2022-09-10 04:09:38 -03:00
LuanRT
88ebb5e2ae fix: replace s placeholders in playback tracking urls 2022-09-10 03:32:43 -03:00
LuanRT
b237b6af4e Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-09-10 03:29:30 -03:00
Nico K
9e618cc576 fix: LiveChatAuthorBadge where MetadataBadge was expected (#179)
* fix `LiveChatAuthorBadge` where `MetadataBadge` was expected

* add "failsafe" for author badges
2022-09-09 19:30:20 -03:00
LuanRT
daf95cfe87 chore: update contribution guidelines 2022-09-09 17:21:48 -03:00
Patrick Kan
bc03c91df9 feat: add PlaylistPanelVideoWrapper parser (#176)
* feat: add `PlaylistPanelVideoWrapper` parser

* fix: `PlaylistPanelVideoWrapper` no counterpart
2022-09-09 15:30:21 -03:00
Akazawa Daisuke
e00be25bf4 feat: add LiveChatAutoModMessage (#177) 2022-09-09 15:29:36 -03:00
LuanRT
c9856a8359 fix: search continuations not being parsed correctly (#173)
* feat: add `TitleAndButtonListHeader`

* fix: continuations not being parsed correctly

* chore: add a test

* chore(package): bump version to 2.0.2

* chore: lint
2022-09-08 21:31:07 -03:00
LuanRT
4b29ad74de chore(docs): rephrase a few things 2022-09-07 03:23:51 -03:00
Patrick Kan
60730a5531 fix: Music#getArtist() and DropdownItem (#170)
* fix: `Music#getArtist()` fails for private artist

* fix: `DropdownItem` inconsistent prop naming
2022-09-06 14:29:29 -03:00
LuanRT
70f2398180 chore(docs): fix another hyperlink 2022-09-06 05:32:20 -03:00
LuanRT
5b3109afef docs: fix hyperlinks
Use actual links otherwise this would not work on NPM
2022-09-06 05:09:00 -03:00
LuanRT
60fe4b1829 chore: tidy up 2022-09-06 04:57:46 -03:00
LuanRT
ddbf9e93da chore(docs): fix download example 2022-09-06 04:02:20 -03:00
LuanRT
e3d483ed75 chore(docs): update examples 2022-09-06 03:37:14 -03:00
LuanRT
320c007396 docs: add video upload example 2022-09-06 03:34:07 -03:00
LuanRT
28a651ea3a feat: add <info>#addToWatchHistory() (#169)
* dev: add `Actions#stats()`

* dev(parser): parse playback tracking urls

* dev: fix a small bug (unrelated)

* feat: add `<info>#addToWatchHistory()`

* docs: update API ref
2022-09-06 02:40:22 -03:00
LuanRT
85fc468cc9 feat: add music#getRecap() (#165)
* dev: add recap renderer parsers

* dev: finish implementation 

* docs: update YouTube Music API ref
2022-09-05 18:08:34 -03:00
LuanRT
f9da261441 chore: add CompactPromotedVideo to ignore list 2022-09-05 03:33:20 -03:00
LuanRT
4484f78394 fix(VideoSecondaryInfo): subscribe_button can also be just a Button 2022-09-05 03:30:44 -03:00
LuanRT
4181969d52 feat: properly type renderer parsers
CardCollection, ChipCloud, Endscreen, PlayerOverlay, PlayerOverlayAutoplay, VideoSecondaryInfo and WatchNextEndScreen.
2022-09-05 03:25:36 -03:00
LuanRT
ecac5f4d7e feat: add ANDROID_MUSIC client 2022-09-05 03:17:07 -03:00
Akazawa Daisuke
a8322e35f5 feat: Add paid chat color info (#164) 2022-09-04 05:57:41 -03:00
LuanRT
3a6f4ffa9d chore(docs): update examples 2022-09-04 05:18:58 -03:00
LuanRT
3dc357bee0 feat: expose parser and YTNodes as public APIs 2022-09-04 05:17:24 -03:00
LuanRT
982a086760 chore(docs): minor fixes and improvements 2022-09-03 20:41:55 -03:00
LuanRT
75959105bd chore(docs): fix typo [skip ci] 2022-09-03 16:44:51 -03:00
LuanRT
80496d30a3 chore(docs): remove unused links [skip ci] 2022-09-03 16:37:57 -03:00
LuanRT
4bddc771b2 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-09-03 16:28:58 -03:00
LuanRT
c26a07dc73 docs: add a more complete download example 2022-09-03 16:28:18 -03:00
Patrick Kan
e498815795 fix: Music#getAlbum()fails for private album ID (#162) 2022-09-03 14:09:29 -03:00
LuanRT
60ef3eabd3 chore: fix stale workflow 2022-09-03 01:05:47 -03:00
LuanRT
1da8043c18 chore: lint 2022-09-03 01:05:30 -03:00
LuanRT
4f015536ac fix: ytmusic formats returning 401 when deciphered (#161)
* fix: sending incomplete video info payload

* fix: check if microformat is `MicroformatData` before parsing
2022-09-03 00:43:17 -03:00
LuanRT
c3f98246f0 docs: add parser info and examples (#160)
* docs: include a parser example in the readme

* docs: fix typo

* docs: rephrasing a few things
2022-09-02 17:31:04 -03:00
Patrick Kan
53cb26546e chore: minor fixes (#159)
* fix: add `params` to `watch_playlist` endpoint

* fix: continuation in `PlaylistPanelContinuation`
2022-09-02 14:24:36 -03:00
LuanRT
e3d38ad107 chore: update labeler workflow [skip ci] 2022-09-02 14:21:37 -03:00
LuanRT
74d53f388a chore: remove unused code 2022-09-02 04:45:52 -03:00
LuanRT
7a7c657733 chore(docs): minor rewording [skip ci] 2022-09-02 04:22:09 -03:00
LuanRT
d34a8d7fc4 chore: add release and labeler workflows [skip ci] 2022-09-01 17:57:02 -03:00
LuanRT
f8c07101bf chore: remove old download examples [skip ci]
TODO: Maybe bring back ffmpeg examples?
2022-09-01 05:29:03 -03:00
LuanRT
dccb2b7e50 chore: remove old readme 2022-09-01 05:19:58 -03:00
LuanRT
573ebf2568 chore: update workflows 2022-09-01 05:14:42 -03:00
LuanRT
898cb56c71 chore(docs): finish most of v2's documentation 2022-09-01 05:10:16 -03:00
Patrick Kan
b9e6e16ce9 feat: add MusicVisualHeader (#157) 2022-08-30 05:11:14 -03:00
Patrick Kan
c99364942c fix: DidYouMean endpoints and add text prop (#158)
* feat: add `text` to `DidYouMean`

* fix: parse correct endpoint in `DidYouMean`
2022-08-30 05:10:51 -03:00
LuanRT
317bca261c feat(download): bring back WEB client (#156)
* refactor: remove dead code and integrate with Jinter

* chore: tidy up
2022-08-29 04:48:33 -03:00
Patrick Kan
173aec65f5 fix: Music#Artistparse err if missing shelves (#155) 2022-08-28 15:50:00 -03:00
LuanRT
13a86cb4e7 feat: add settings page parser (#154)
* feat: add settings page parsers

* fix(AccountManager): small ts error

* feat: add `CopyLink` & `SettingsCheckbox`

* deps: remove “flat” dependency
2022-08-28 05:11:11 -03:00
Patrick Kan
05b4593e0a feat: fix music#library.getArtists() and add MusicShelf.bottom_button (#152)
* fix: #143

* feat: add `bottom_button` to `MusicShelf`
2022-08-25 17:14:32 -03:00
Patrick Kan
6fe4d235ff feat: add MusicSortFilterButton (#151) 2022-08-25 02:06:52 -03:00
LuanRT
f4ce4d2f74 feat: add account info parsers 2022-08-25 01:43:05 -03:00
LuanRT
541cdc455f feat: add parsers for TimeWatched 2022-08-24 06:13:19 -03:00
LuanRT
c000bd8d5f docs(parser): fix typos 2022-08-24 02:46:56 -03:00
Patrick Kan
f3d77b3e97 Add end_icons to MusicCarouselShelfBasicHeader and fix music#getPlaylist() (#149)
* ft: add end_icons to MusicCarouselShelfBasicHeader

* fix: `music#getPlaylist()` breaking playlist_id
2022-08-22 16:46:52 -03:00
Ben Gerard
22b2953ec8 fix: captions should be a PlayerCaptionsTracklist (#148) 2022-08-21 18:56:57 -03:00
Patrick Kan
a4965ee43d fix: playlist radios misidentified as videos (#147) 2022-08-21 18:55:58 -03:00
LuanRT
842c185f4d chore(docs): improve auth example 2022-08-20 05:28:54 -03:00
LuanRT
790d528a2d tests: use ts-jest for tests 2022-08-20 04:05:34 -03:00
LuanRT
ed79551314 chore: update tsconfig 2022-08-20 04:02:13 -03:00
LuanRT
34281e2445 refactor: migrate parsers to TS (#133)
* dev: finish top-level parsers TS migration

* dev: migrate menu renderers to TS

* chore: fix ts errors

* dev: finish ts migration 🎉
2022-08-20 03:18:17 -03:00
Patrick Kan
b101a39d30 chore: PlaylistPanel fixes (#146)
* fix:`PlaylistPanel` possible content type mismatch

* fix: `PlaylistPanel` err when no continuation
2022-08-19 06:52:47 -03:00
Patrick Kan
dc2f0055cc feat: improve parsing (#145)
* fix: err in `MusicDetailHeader` when no duration

* feat: get video duration from more places
2022-08-19 06:02:01 -03:00
dependabot[bot]
ecdac38458 chore(deps): bump undici from 5.8.0 to 5.8.2 (#144)
Bumps [undici](https://github.com/nodejs/undici) from 5.8.0 to 5.8.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.8.0...v5.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-18 17:45:39 -03:00
Patrick Kan
31326ec9eb refactor: misc fixes and additions (#142)
* feat: add `header` to `Grid` parser

* feat: parse title in `MusicHeader`

* feat: improve parsing of artist-type items
2022-08-18 05:35:24 -03:00
Patrick Kan
dba34dc5ae feat(ytmusic): music#Playlist fixes and additions (#138)
* feat: add MusicEditablePlaylistDetailHeader parser

* feat: more info in `DropdownItem`

* fix: empty `year` value in `MusicDetailHeader`

* fix(ytmusic#Playlist): header err w/ own playlists

* feat: include reload continuation in `MusicShelf`

* feat(ytmusic): add getSuggestions() to Playlist
2022-08-14 20:39:31 -03:00
Patrick Kan
713fd13c74 fix(music#Library): sort_by err when items <= 1 (#137) 2022-08-13 19:46:55 -03:00
Patrick Kan
f6a2a418be feat(ytmusic): implement music#Library (#136)
* feat: add ItemSectionTab and related parsers

* feat: add `continuation` to `Grid`parser class

* feat (ytmusic): implement music#getLibrary()

* Improve album fetch in `MusicResponsiveListItem`

* music#Library: return [] for empty results

* feat: add `Dropdown` & `DropdownItem` parsers

* feat: add `CreatePlaylistDialog` parser

* feat: add `create_playlist` to NavigationEndpoint

* feat: add `AutomixPreviewVideo` parser

* feat: improve parsing of items

* fix: `PlaylistPanel` continuation

* feat: more args in `Actions#next`

* feat: add `PlaylistPanelContinuation` to `Parser`

* chore: update parser-map

* music#Library: refactor + add shuffle songs opt

* feat: add `endpoint` to `DropdownItem`

* feat: add `end_items` to `ItemSectionTabbedHeader`

* feat(ytmusic): add `sort_by` to `music#Library`
2022-08-13 17:39:35 -03:00
LuanRT
e82302a6ea chore: revert commit 59d37e9ed6 2022-08-12 05:45:59 -03:00
LuanRT
59d37e9ed6 chore: bump client versions 2022-08-12 00:34:58 -03:00
LuanRT
c10cce1e2a chore: include androidSdkVersion param in Android requests 2022-08-12 00:30:14 -03:00
LuanRT
63ae9061eb chore: include alt param in all requests 2022-08-11 22:58:03 -03:00
LuanRT
03b183be70 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-08-11 20:47:36 -03:00
LuanRT
2d7fe04a8a fix: oopsie, forgot to remove the video id while debugging 2022-08-11 20:47:21 -03:00
patrickkfkan
4d6067937a fix: build error caused by music#Playlist.getRelated() (#135)
* fix(ytmusic): title check in `Playlist#getRelated`
2022-08-11 20:43:38 -03:00
LuanRT
52207df393 chore: lint 2022-08-11 20:41:54 -03:00
LuanRT
9a914e29ba Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-08-11 20:37:01 -03:00
LuanRT
34022fddfb hotfix: use Android client when requesting initial video info 2022-08-11 20:35:30 -03:00
patrickkfkan
9b4d86b81f feat(ytmusic): add music#getPlaylist() (#131)
* add music#getPlaylist()
* fix: lint errors
2022-08-10 14:11:31 -03:00
patrickkfkan
dc79b19d56 refactor: migrate MusicCarouselShelfBasicHeader to TypeScript and add more_content prop (#132)
* refactor: migrate `MusicCarouselShelfBasicHeader` to TS

* feat: `MusicCarouselShelfBasicHeader.more_content`
2022-08-09 15:37:19 -03:00
Daniel Wykerd
ad3ab4f637 docs: OAuth example 2022-08-08 15:16:58 -03:00
LuanRT
60ff0513f1 docs: fix typos / rephrasing 2022-08-07 06:40:45 -03:00
LuanRT
4ab2bb744a chore: lint 2022-08-07 06:17:43 -03:00
LuanRT
40fc24b043 refactor!: fix inconsistent use of SuperParsedResult 2022-08-07 06:15:55 -03:00
LuanRT
709c448053 refactor!: migrate core renderers to TypeScript 2022-08-07 06:14:09 -03:00
LuanRT
3833b333a7 refactor: migrate MusicCarouselShelf to TypeScript 2022-08-05 16:33:51 -03:00
LuanRT
38280290f7 fix(ytmusic): oopsie, forgot to declare player_overlays 2022-08-04 16:54:38 -03:00
LuanRT
d5f34982f4 feat(ytmusic): add music#getInfo()
Already functional but still WIP.
2022-08-04 16:49:20 -03:00
LuanRT
3ff3d3c633 feat: add SimpleCardContent
Related: #129
2022-08-04 02:16:12 -03:00
LuanRT
a788c9c80f feat: migrate all playlist renderers to TypeScript 2022-08-04 01:56:53 -03:00
LuanRT
9e2443d1aa chore(docs): fix minor typos and other things 2022-08-03 18:43:21 -03:00
LuanRT
bb3ed9dcd3 docs: update v2 API ref 2022-08-03 18:35:35 -03:00
LuanRT
51f9eb15ae docs(comments): update result types 2022-08-03 17:10:56 -03:00
LuanRT
d6398296c3 docs: update examples 2022-08-03 17:06:00 -03:00
LuanRT
af6856ced4 chore: tidy things up
Move a few things here and there. Organization makes life easier.
2022-08-03 03:34:59 -03:00
LuanRT
3cdaab8b7a docs: update live chat examples 2022-08-01 15:55:27 -03:00
LuanRT
daaba3745e feat: improve LiveChat types 2022-08-01 15:54:54 -03:00
LuanRT
323b90a98c feat: add LiveChatProductItem and migrate LiveChatBanner to TypeScript 2022-08-01 15:52:25 -03:00
LuanRT
3abcde7e67 refactor!: rewrite MusicNavigationButton to TypeScript
Plus fix “endpoint” prop, it is `clickCommand` and not `navigationEndpoint`.
2022-08-01 03:28:15 -03:00
LuanRT
2599e734b8 fix(ytmusic): music#getRelated() now works again
Like nearly all YouTube Music methods, this one was also broken due to a recent refactor on the parser.
2022-08-01 03:06:30 -03:00
LuanRT
c10006fa57 chore: remove unused constants 2022-07-31 14:59:36 -03:00
LuanRT
61f8b2a9a0 chore: remove unneeded checks 2022-07-30 05:48:46 -03:00
LuanRT
cdbdfec057 chore: lint 2022-07-30 05:40:43 -03:00
LuanRT
4d332402db fix(ytmusic): fix music#getLyrics() & music#getUpNext()
These were broken due to recent changes in the parser — both should be fixed now. Note that `music#getRelated()` is still broken.
2022-07-30 05:37:23 -03:00
LuanRT
c66940ae65 refactor(ytmusic): migrate Explore & Library to TypeScript 2022-07-30 04:18:12 -03:00
LuanRT
ff9aeeedce refactor: rewrite Library to TypeScript 2022-07-29 16:09:11 -03:00
LuanRT
88a6ee907e chore: lint 2022-07-29 06:58:49 -03:00
LuanRT
72c3af84b0 refactor(ytmusic)!: rewrite Album to TypeScript 2022-07-29 06:58:28 -03:00
LuanRT
99233bcf7a refactor!: rewrite History to TypeScript 2022-07-29 06:20:58 -03:00
LuanRT
adae925367 refactor!: rewrite Analytics to TypeScript (#122)
* refactor: migrate all analytics’ classes to TypeScript

Also, add AnalyticsShortsCarouselCard and AnalyticsRoot.
2022-07-29 05:39:34 -03:00
LuanRT
5a99190136 fix(linter): oops, wrong extension 2022-07-29 01:19:55 -03:00
LuanRT
6008d4cf0d chore: update workflows 2022-07-29 01:16:35 -03:00
LuanRT
f4b947f8e2 fix(linter): ignore compiled protobuf 2022-07-29 01:11:20 -03:00
LuanRT
00cd35867a refactor: use “prepare” script instead of “prepublishOnly” 2022-07-29 01:07:49 -03:00
LuanRT
7ba09a66d8 refactor: migrate NotificationsMenu to TypeScript 2022-07-29 01:00:56 -03:00
LuanRT
c16d632b31 fix: race condition causing “update-credentials” to fire multiple times 2022-07-28 05:11:10 -03:00
Daniel Wykerd
9ef765dbc1 feat: allow users to cache OAuth credentials (#121)
Use `UniversalCache` instance to cache user credentials

Opt-in via `OAuth#cacheCredentials()`

* chore: lint
2022-07-28 00:04:07 -03:00
Daniel Wykerd
dbfcb36fd7 fix: TabbedFeed#getTab to parse response. (#120)
* fix: TabbedFeed#getTab to parse response.

* fix: Channel parser and example

* refactor: migrate youtube Search to TS

* chore: lint
2022-07-26 17:29:30 -03:00
LuanRT
0393ab7f38 chore: add .npmignore
This is needed so build output can be included in the package when publishing to npm.
2022-07-25 18:28:28 -03:00
Daniel Wykerd
eb5d49d14e refactor: replace xmlbuilder2 with linkedom (#119)
* refactor: replace xmlbuilder2 with linkedom

This reduces our bundle size from 909mb to 530mb

* chore: lint
2022-07-25 16:48:46 -03:00
Daniel Wykerd
a83518d021 refactor: allow uploads of streams (#117)
This allows uploading from a ReadableStream or File instead of reading
the whole file into memory first.
2022-07-25 15:51:42 -03:00
LuanRT
95079ced09 feat: add support for uploading videos (#115)
* chore: add video upload url

* feat!: add support for uploading videos

This is probably complete but I will do a self-review later today.

* style: align comments

* style: lint code

* chore: tidy things up
2022-07-25 04:45:55 -03:00
Daniel Wykerd
616b1405c3 refactor: generate typescript protobuf encoders (#114)
This also removes dependency `buffer` for browsers.

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2022-07-23 15:16:07 -03:00
Daniel Wykerd
ef6ec59402 feat: smaller user-agent list (#112) (#113) 2022-07-23 15:05:53 -03:00
LuanRT
a2103963b4 feat: add Studio#setThumbnail() method (#111)
* feat: add support for protobuf payloads to `Actions#execute()`

* chore: compile proto definitions file

* feat(wip): add `Studio` class and implement `Studio#setThumbnail()` method

* fix: check if parameters are missing
2022-07-23 02:45:47 -03:00
LuanRT
8ed6cc9e24 chore(docs): add sponsor (SerpApi) 2022-07-23 02:32:01 -03:00
Daniel Wykerd
9c44cfc7f8 fix: add missing playlist_length (#110)
* fix: add missing playlist_length

Also convert to TS

* chore: lint
2022-07-22 17:08:32 -03:00
dependabot[bot]
c487a65e8f chore(deps): bump undici from 5.7.0 to 5.8.0 (#109)
Bumps [undici](https://github.com/nodejs/undici) from 5.7.0 to 5.8.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.7.0...v5.8.0)

---
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>
2022-07-22 17:08:13 -03:00
LuanRT
9c7850d197 refactor(ytmusic): rewrite Artist to TypeScript 2022-07-22 05:21:02 -03:00
LuanRT
c12b1482fe feat(ytmusic): expose Message renderer in the Search object 2022-07-22 05:19:17 -03:00
LuanRT
851afddf51 refactor(ytmusic): rewrite HomeFeed to TypeScript 2022-07-22 03:14:56 -03:00
LuanRT
8b9cd236ae chore: fix warnings 2022-07-21 17:17:46 -03:00
LuanRT
0fb0c2318a fix: filtered search & continuations not working correctly 2022-07-21 17:12:32 -03:00
LuanRT
dfd09e9683 fix: DidYouMean & ShowingResultsFor throwing type errors 2022-07-21 15:36:17 -03:00
LuanRT
6da69b4f18 feat: update InnerTubePayload protobuf
Refer to #104
2022-07-21 15:14:13 -03:00
LuanRT
60e6326402 style: format code 2022-07-21 03:51:28 -03:00
LuanRT
4bf4639902 chore: fix browser bundle, #108 2022-07-20 16:51:33 -03:00
LuanRT
0f8c25a5f0 chore: fix linter 2022-07-20 16:33:29 -03:00
LuanRT
6a5ebeb8ee chore: clean up build steps 2022-07-20 16:28:51 -03:00
Daniel Wykerd
fb68e6bcfe feat!: better cross runtime support (#97)
* refactor: remove dependancies

removes node-forge and uuid in favor of Web APIs

* refactor!: commonjs to es6

To aid with #93 I will make all my changes in TypeScript instead.
This is the first step into making that happen.

Used: https://github.com/wessberg/cjstoesm

* refactor!: NToken and Signature TS files

Bring this PR up to speed with #93

* feat: cross platform cache (WIP)

this is untested!
should remove idb as dependecy.

* feat: EventEmitter polyfill

* refactor: remove events

* feat: HTTPClient based on Fetch API (WIP)

* refactor!: parsers refactor (WIP)

Initial TS support for parsers as per #93

This adds several type safety checks to the parser which'll help to
ensure valid data is returned by the parser.

* refactor!: parsers refactor (WIP)

Bring more in line with the existing implementations & make less verbose

* refactor!: parser refactor

I was overcomplicating things, this is much simpler and compatible with
the existing JS API

* fix: some missed parsers while refactoring

* fix: better type inferance for parseResponse

* feat(TS): typesafe YTNode casts

* feat: more type safety in YTNode and Parser

* refactor: VideoInfo download with fetch & TS (WIP)

Again, this also does some work for #93

* fix: LiveChat in VideoInfo

* refactor!: more typesafety in parser

* refactor!: VideoInfo almost completed

* refactor!: player and session refactors

- Remove the Player class' dependance on Session.
- Add additional context to the Session.

* refactor!: move auth logic to Session (WIP)

* refactor: TS port for Actions and Innertube

My fingers hurt from typing out all those types :-P

* refactor: NavigationEndpoint TS

this is still a WIP and should be improved.
NavigationEndpoint should probably be refactored further.

* refactor!: VideoInfo compiles without errors

* chore: delete old player

* fix: import errors

It compiles and runs!!

* fix: Utils import fixes

* fix: several runtime errors

* fix: video streaming

* chore: remove console.log debugging

Whoops, forgot to remove these before I pushed the previous commit

* chore: remove old unused dependencies

* fix: typescript errors

Now emitting declarations and source maps

* refactor: TS feed

* chore: delete old Feed

* refactor: move streamToIterable into Utils

* refactor: AccountManager TS

* refactor: FilterableFeed to TS

* refactor: InteractionManager to TS

* refactor: PlaylistManager to TS

* refactor: TabbedFeed to TS

* refactor: Music to TS (WIP)

more work to be done, see TODO comments

* fix: getting the tests to pass (6/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1152 ms)
      ✕ Should search on YouTube Music (705 ms)
      ✕ Should retrieve YouTube search suggestions (722 ms)
      ✓ Should retrieve YouTube Music search suggestions (233 ms)
    Comments
      ✓ Should retrieve comments (585 ms)
      ✕ Should retrieve next batch of comments (221 ms)
      ✕ Should retrieve comment replies (1 ms)
    General
      ✕ Should retrieve playlist with YouTube (732 ms)
      ✓ Should retrieve home feed (838 ms)
      ✓ Should retrieve trending content (543 ms)
      ✓ Should retrieve video info (639 ms)
      ✕ Should download video (5 ms)

* fix: tests (7/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1984 ms)
      ✕ Should search on YouTube Music (1139 ms)
      ✕ Should retrieve YouTube search suggestions (1433 ms)
      ✓ Should retrieve YouTube Music search suggestions (529 ms)
    Comments
      ✓ Should retrieve comments (324 ms)
      ✓ Should retrieve next batch of comments (395 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (653 ms)
      ✓ Should retrieve home feed (1085 ms)
      ✓ Should retrieve trending content (513 ms)
      ✓ Should retrieve video info (921 ms)
      ✕ Should download video (3 ms)

* fix: download tests (8/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1293 ms)
      ✕ Should search on YouTube Music (927 ms)
      ✕ Should retrieve YouTube search suggestions (1250 ms)
      ✓ Should retrieve YouTube Music search suggestions (258 ms)
    Comments
      ✓ Should retrieve comments (803 ms)
      ✓ Should retrieve next batch of comments (511 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (528 ms)
      ✓ Should retrieve home feed (1047 ms)
      ✓ Should retrieve trending content (548 ms)
      ✓ Should retrieve video info (825 ms)
      ✓ Should download video (1779 ms)

* fix: tests (9/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1276 ms)
      ✕ Should search on YouTube Music (955 ms)
      ✓ Should retrieve YouTube search suggestions (661 ms)
      ✓ Should retrieve YouTube Music search suggestions (491 ms)
    Comments
      ✓ Should retrieve comments (624 ms)
      ✓ Should retrieve next batch of comments (353 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (672 ms)
      ✓ Should retrieve home feed (1277 ms)
      ✓ Should retrieve trending content (999 ms)
      ✓ Should retrieve video info (1106 ms)
      ✓ Should download video (2514 ms)

* feat: key based type validation for parsers

* fix: comments tests pass (10/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (938 ms)
      ✕ Should search on YouTube Music (850 ms)
      ✓ Should retrieve YouTube search suggestions (528 ms)
      ✓ Should retrieve YouTube Music search suggestions (224 ms)
    Comments
      ✓ Should retrieve comments (518 ms)
      ✓ Should retrieve next batch of comments (337 ms)
      ✓ Should retrieve comment replies (358 ms)
    General
      ✕ Should retrieve playlist with YouTube (466 ms)
      ✓ Should retrieve home feed (1051 ms)
      ✓ Should retrieve trending content (623 ms)
      ✓ Should retrieve video info (863 ms)
      ✓ Should download video (2656 ms)

* refactor: type safety checks removing @ts-ignore

* fix: playlist tests pass (11/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (991 ms)
      ✕ Should search on YouTube Music (924 ms)
      ✓ Should retrieve YouTube search suggestions (606 ms)
      ✓ Should retrieve YouTube Music search suggestions (225 ms)
    Comments
      ✓ Should retrieve comments (393 ms)
      ✓ Should retrieve next batch of comments (284 ms)
      ✓ Should retrieve comment replies (252 ms)
    General
      ✓ Should retrieve playlist with YouTube (578 ms)
      ✓ Should retrieve home feed (1148 ms)
      ✓ Should retrieve trending content (541 ms)
      ✓ Should retrieve video info (799 ms)
      ✓ Should download video (1419 ms)

* fix: all tests pass for node 🎉

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1053 ms)
      ✓ Should search on YouTube Music (761 ms)
      ✓ Should retrieve YouTube search suggestions (453 ms)
      ✓ Should retrieve YouTube Music search suggestions (221 ms)
    Comments
      ✓ Should retrieve comments (627 ms)
      ✓ Should retrieve next batch of comments (412 ms)
      ✓ Should retrieve comment replies (268 ms)
    General
      ✓ Should retrieve playlist with YouTube (565 ms)
      ✓ Should retrieve home feed (775 ms)
      ✓ Should retrieve trending content (498 ms)
      ✓ Should retrieve video info (875 ms)
      ✓ Should download video (1364 ms)

* build: working Deno bundle

Still need to test whether this bundle works in the browser

* docs: update deno example to download video

* refactor: MusicResponsiveListItem to TS

* docs: TSDoc for Parser helpers

* docs: Parser documentation for TS

* docs: add note about parseItem and parseArray

* test: remove browser tests since they're identical

* feat: browser support and proxy example

* fix: PlaylistManager TS after merge

* feat: in-browser video streaming

* refactor: cleanup the Dash example

* feat: allow custom fetch implementations

* feat: fetch debugger

* fix: OAuth login

* refactor: remove file extensions from imports

* refactor: build scripts

* fix: CustomEvent on node

* fix: LiveChat

* fix: linting

* fix: liniting in build-parser-json

* chore: update test workflow

* fix: NToken errors after lint fixes

* fix: codacy complaints

* docs: update to reflect changes

Definitly needs more work but its a start

* refactor: cleanup imports/exports

* fix: browser example

- Remove user-agent before making request.
- Fix cache on browsers

* fix: cache on node

* fix: stupid mistake

* refactor: Session#signIn to wait untill success

This also splits the 'auth' event up into 3 distinct events:
- 'auth' -> fired on success
- 'auth-pending' -> fired when pending authentication
- 'auth-error' -> fired when an error occurred

* refactor: freeze Constants

* refactor: cleanup HTTPClient Request

* refactor: debugFetch readability

* chore: lint

* refactor: replace jsdoc with tsdoc eslint plugin

remove @param annotations without descriptions

* fix: bunch of liniting warnings

* refactor: better inference on YTNode#is

As suggested by @MasterOfBob777

* fix: linting warnings

* revert: undici import

* refactor: rename `list_type` to `item_type`
2022-07-20 14:06:12 -03:00
LuanRT
e2f455d7bd chore(docs): add contribution guidelines file 2022-07-18 04:36:21 -03:00
LuanRT
39d2c4c09d chore(docs): move things around a bit 2022-07-13 04:51:09 -03:00
LuanRT
2e3b1c2bf2 chore(docs): add a note about our Discord server 2022-07-13 04:49:19 -03:00
LuanRT
0d4bca5a9d chore (docs): add discord badge 2022-07-13 04:32:00 -03:00
LuanRT
1ce2feb18b style: lint code
Also, remove “strict” rule in favor of typescript (#93, #97)
2022-07-12 23:28:56 -03:00
LuanRT
7ded405de0 chore(package): build 2022-07-12 23:25:27 -03:00
LuanRT
7400b8a9d9 fix(pm): check before setting video ids 2022-07-12 19:15:42 -03:00
LuanRT
2247026da1 chore: fix typo 2022-07-12 18:33:52 -03:00
LuanRT
d8266ff786 refactor!: rewrite PlaylistManager
Probably fixes mentioned issues.

#100, #101
2022-07-12 18:22:53 -03:00
LuanRT
d1f2369e43 fix(build): mark all deps as external
I don't really like the idea of having yet another config file just so we can get all deps from package.json and mark them as external. So let's add them to the build command manually.

#95
2022-07-12 02:05:46 -03:00
LuanRT
4fe349389c chore(package): build 2022-07-11 06:57:51 -03:00
LuanRT
68cb841c00 refactor!: finish parser migration
Finally! :)

This removes all code related to the old parser.

#65
2022-07-11 06:19:10 -03:00
LuanRT
947fd7895b chore: update type declarations 2022-07-11 06:13:21 -03:00
LuanRT
0509b704a8 refactor: rewrite Innertube#getNotifications() to use the new parser 2022-07-11 04:07:39 -03:00
LuanRT
f924a39409 feat(parser): allow parser to find renderers by name
Now we can organize renderers in individual folders and fix the mess that `../contents/classes` is!
2022-07-11 02:47:58 -03:00
LuanRT
03f9fc5c2e chore(docs): rewording 2022-07-10 22:00:44 -03:00
LuanRT
8a5073b0b9 refactor: rewrite search suggestions logic (#92)
* refactor: rewrite YouTube Music search suggestions

The search suggestions method can be found under `Innertube#music.getSearchSuggestions(query)`

* feat: allow `execute(..)` to return parsed data

This simplifies how response data is handled and also makes it easier for end users to write custom functionality.

* style: lint code

* chore: change a few things

* refactor: rewrite YouTube search suggestions

* chore(package): build

* chore: update type declarations

* chore: fix tests
2022-07-10 17:30:20 -03:00
LuanRT
0356dafa96 docs: fix typos 2022-07-09 14:53:53 -03:00
LuanRT
bd7279f800 docs: remove uneeded badge
This is kind of ugly so let's get rid of it.
2022-07-07 15:43:08 -03:00
LuanRT
11d553b2c0 chore: rephrase a few things in the parser documentation 2022-07-07 03:42:59 -03:00
LuanRT
670b918642 style: format code 2022-07-07 03:42:21 -03:00
LuanRT
5a14fe3c4c chore: add additional build option
Add the `--keep-names` option to the build command.

The library sometimes uses constructor names to differentiate between InnerTube renderers. I noticed that after building the code in prod mode class names are drastically renamed and a few things ended up breaking. This fixes all these problems.
2022-07-07 03:39:45 -03:00
LuanRT
ae1a2a7f84 chore: temporarily remove decipher tests
Doesn't look like we can run this on Node 12, may be due to the use of optional chaining.
2022-07-07 00:37:26 -03:00
LuanRT
1837d4929c fix(tests): require deciphers only when needed 2022-07-07 00:20:48 -03:00
LuanRT
d729972251 chore(tests): check node version before running decipher tests 2022-07-07 00:14:10 -03:00
LuanRT
d7267d9aa5 chore: update workflows 2022-07-07 00:01:06 -03:00
LuanRT
650b563301 style: lint code 2022-07-06 23:55:42 -03:00
LuanRT
fd52556603 chore: update bundled code 2022-07-06 23:50:15 -03:00
LuanRT
ff81c2afe8 chore: update type declarations 2022-07-06 23:48:17 -03:00
LuanRT
9c97434e5e chore: tidy things up and fix typos 2022-07-06 23:47:38 -03:00
LuanRT
021a7fd97a refactor(utils): add tmpdir function 2022-07-06 23:45:57 -03:00
LuanRT
a011f62a90 deps: remove tmpdir dependency 2022-07-06 23:44:32 -03:00
LuanRT
dff535a9e2 fix: refactor Analytics to use memo 2022-07-06 17:34:31 -03:00
Bob Varioa
f52d15cdb0 Make project multiplatform (#91)
* Prefer `c ? x : y` over `c && x || y`

* Avoid unnecessary asssignment expressions

* Prefer switch statements over object lookup tables

* Add an .editorconfig

* Fix style issues

* Fix mentioned issues

* remove dynamic require

* Introduce esbuild as a build system

* Add cross platform stream api

* Replace 'fs' with custom cache api

* Add cross platform crypto api

* Add misc. dependencies

* Create multi-platform tests

* Update package-lock, Add build files

* Pull from upstream

* Fix linting issues, and update build files

* Fix comments issues

* Regenerate types, add source maps

Co-authored-by: bob <bob.varioa@gmail.com>
2022-07-06 16:47:48 -03:00
LuanRT
84d5edb6f0 refactor: rewrite OAuth and Requester (#90)
* chore: update type declarations

* dev: refactor oauth & requester

* chore: tidy things up

* chore: remove unneeded check
2022-07-04 16:34:02 -03:00
LuanRT
d7d6a4e019 style: remove white space 2022-07-03 04:40:26 -03:00
LuanRT
3bdcdf7cf1 chore(docs): add more info about the parser 2022-07-03 04:28:46 -03:00
LuanRT
b314458ed9 chore(docs): rephrase 2022-07-03 01:40:12 -03:00
LuanRT
1d62e469a9 refactor: rewrite Comments Section logic (#88)
* feat: add core comments section classes

* chore: update type declarations

* chore: fix linter warnings

* style: fix linter

* chore: update tests

* chore(tests): fix typo

* chore(tests): fix typo x2

* fix(tests): `getReplies()` method is only present in `CommentThread` and not `Comment`

* chore(tests): fix comment id path

* chore(tests): remove outdated code

* chore(tests): fix results path

* chore: enforce code style

* chore: update type declarations

* docs: add examples and documentation

* chore(docs): fix paths

* chore(docs): fix more paths

* chore(docs): fix `Comments.js` path

* chore(docs): fix typo

* chore(docs): mention example file

* chore(examples): fix imports

* chore(examples): fix typo
2022-07-02 19:55:33 -03:00
Bob Varioa
0a851bde31 refactor: remove confusing code practices (#87)
* Prefer `c ? x : y` over `c && x || y`

* Avoid unnecessary asssignment expressions

* Prefer switch statements over object lookup tables

* Add an .editorconfig

* Fix style issues

* Fix mentioned issues

Co-authored-by: bob <bob.varioa@gmail.com>
2022-07-01 19:37:59 -03:00
LuanRT
3e2b932844 style: format code 2022-06-29 20:57:16 -03:00
LuanRT
263b4887c3 feat(livechat): add LiveChatPaidSticker action 2022-06-29 19:44:33 -03:00
LuanRT
4f994c338b refactor: rewrite Live Chat logic (#85)
* dev: start LiveChat refactor

* dev: implement simple module system to separate classes

+ add a few Live Chat actions

* dev: add fundamental Live Chat classes

* chore: update type declarations

* feat: finalize Live Chat

Now supporting almost all kinds of messages! Next up: add a ability to send messages.

* chore: update type declarations

* chore: update contributors list

* feat(livechat): add `sendMessage()` method

* chore: remove unneeded files

* style: format code

* chore: remove outdated examples

* chore: update tests

* chore: remove trailing spaces

* chore: remove trailing spaces x2
2022-06-29 16:51:51 -03:00
Daniel Krásný
ef9a22e85a feat(ytmusic): multiple video authors (#83)
Parse all `authors` from a music video. For legacy reasons, `author` key is kept returning the first entry. Definitions updated respectively.
2022-06-27 19:15:47 -03:00
Daniel Krásný
8849a01ecf fix(ytmusic): Multiple authors/artists (#82)
(legacy parser)

Changed return types of SongResultItem's key artist and VideoResultItem's key author to array of strings instead of string.
2022-06-27 14:15:42 -03:00
LuanRT
a948c2e480 chore: update issue templates 2022-06-25 03:58:35 -03:00
LuanRT
f5c6dbc63e fix: add PlaylistHeader renderer
#81
2022-06-24 04:26:39 -03:00
LuanRT
829181ba6f chore: remove unneeded code 2022-06-23 15:30:11 -03:00
LuanRT
7ec6d6dd21 feat: add YouTube livechat renderers
Next up: refactor old livechat parser
2022-06-23 03:46:53 -03:00
LuanRT
d2b3eead41 fix: ItemSection target_id not being parsed 2022-06-23 03:13:15 -03:00
LuanRT
96857ccadf feat(ytmusic): expose additional segments in albums 2022-06-23 02:47:03 -03:00
LuanRT
c24e6256c5 chore: fix typo 2022-06-22 19:11:16 -03:00
LuanRT
00c2db791f style: format & remove unneeded code 2022-06-22 19:05:08 -03:00
LuanRT
20556970a7 feat(ytmusic): add support for stations in search results, #78 2022-06-22 05:46:33 -03:00
Daniel Wykerd
1681a9b84c feat(player): improved decipher logic (#79)
* feat(player): improved decipher logic

- Improve the deciphering logic for Signatures and NTokens.
- This makes NToken transforms more than 20x faster
- It also improves caching of the player drastically, by only keeping
the processed responses in binary format. Bringing down the cache
per player from 1.8MB to less than 400 bytes

* fix: linting errors

* fix: tests

* refactor: replace TS enum with ordinary JS objects
2022-06-21 14:29:13 -03:00
LuanRT
b3c5e340af chore: update lock file 2022-06-21 04:08:31 -03:00
LuanRT
bb3f3cc584 fix(ytmusic): check if id is valid before proceeding 2022-06-21 03:59:44 -03:00
LuanRT
86291fe1f9 chore: fix typo 2022-06-21 03:35:49 -03:00
LuanRT
6eef4b746b fix(ytmusic): parse nav endpoints only if they exist
#78
2022-06-21 03:34:56 -03:00
王超毅
4088ef59c6 fix(VideoInfo): Fix the problem that chooseFormat still returns empty even though there are videos to adapt.
when the video exists in 2160p and the default format is mp4, the selected format output is empty
2022-06-21 01:17:55 -03:00
王超毅
4a7c9d7b31 fix(examples): fix download stream callback console video info 2022-06-21 01:17:55 -03:00
LuanRT
36f02cdcdb chore(ytmusic): use optional chaining on duration text 2022-06-20 18:10:35 -03:00
LuanRT
97d4cc1056 feat(ytmusic): add support for retrieving albums 2022-06-20 16:33:51 -03:00
LuanRT
e90285bfab chore: update type declarations 2022-06-20 16:33:12 -03:00
LuanRT
7fc9b526b0 feat(ytmusic): add support for artists
Available through `Innertube#music.getArtist(id: string)`

#78
2022-06-20 06:08:24 -03:00
LuanRT
99b88e2684 chore: update type declarations 2022-06-20 06:05:27 -03:00
LuanRT
748e34758f feat: tidy things up and implement more renderers
- Finished Library parser
- Fixed search continuations
- Improved channel parser
- Improved playlist parser
- Added support for posts of type poll
- Improved History parser
- Removed redundant code
2022-06-20 03:02:42 -03:00
LuanRT
a556aacfdd chore: regenerate type declarations 2022-06-20 02:51:19 -03:00
LuanRT
9ffaaacb3e style: format code 2022-06-18 05:22:54 -03:00
LuanRT
4c7a42d8d4 fix: search continuations should return a Search class
Why? To keep things consistent.
2022-06-18 05:16:21 -03:00
LuanRT
1d2c1ed69b chore: regenerate type declarations 2022-06-18 05:10:53 -03:00
LuanRT
5af2a9972e chore: remove unused var 2022-06-17 16:34:05 -03:00
LuanRT
1efbef6f49 chore(ytmusic): simplify home feed 2022-06-17 16:32:13 -03:00
LuanRT
4e1f6af736 chore: update jsdoc comments 2022-06-17 16:19:52 -03:00
LuanRT
98e7afda87 feat: add suppory for YouTube Music's “Explore” tab 2022-06-17 16:17:24 -03:00
LuanRT
58809c2280 chore: remove unused URLs 2022-06-16 20:58:08 -03:00
LuanRT
1484e3c2aa dev: always use InnerTube prod url 2022-06-16 20:52:00 -03:00
LuanRT
e0546944a8 chore: fix typo 2022-06-16 20:14:31 -03:00
LuanRT
d246008eab chore: add other InnerTube API urls 2022-06-16 20:10:11 -03:00
LuanRT
455556ba89 feat: add all watch history feed action renderers 2022-06-16 16:09:29 -03:00
LuanRT
eaa16244d2 fix(analytics): rename old Thumbnail module path 2022-06-16 15:26:00 -03:00
LuanRT
919a35d024 feat: wrap Library sections around Proxy trap 2022-06-16 15:06:31 -03:00
LuanRT
d54fc282ad chore: remove a few deprecated methods 2022-06-16 14:59:58 -03:00
LuanRT
51f7adf397 fix: rename YouTube Music renderers 2022-06-16 14:51:50 -03:00
Daniel Wykerd
d990fc9b88 fix: lint 2022-06-16 13:46:43 -03:00
Daniel Wykerd
418dcac80a docs(download): examples for downloading videos 2022-06-16 13:46:43 -03:00
Daniel Wykerd
60075f8726 fix: chooseFormat filtering improvements 2022-06-16 13:46:43 -03:00
Daniel Wykerd
41aa54b8d9 fix: download to use getBasicInfo 2022-06-16 13:46:43 -03:00
Daniel Wykerd
662bccf2c2 chore: update .gitignore
Ignore videos and tmp/ directory
2022-06-16 12:03:24 +02:00
LuanRT
abe045762b chore(docs): rephrase 2022-06-15 23:55:57 -03:00
LuanRT
67d526e15d chore(docs): add warning about upcoming changes 2022-06-15 23:54:45 -03:00
LuanRT
940b8322cc chore(docs): add warning about upcoming changes 2022-06-15 23:53:55 -03:00
LuanRT
d6bbe8f183 fix(linter): remove unneeded vars and add jsdoc 2022-06-15 23:29:30 -03:00
LuanRT
28d51fcc4f perf: use getBasicInfo() 2022-06-15 23:29:30 -03:00
LuanRT
e8a81084e6 fix(ytmusic): rename old class types 2022-06-15 23:29:30 -03:00
Daniel Wykerd
4ef546b3f0 fix: emit info Innertube.download 2022-06-15 23:29:30 -03:00
Daniel Wykerd
ec5a2aa7fd fix: decipher 2022-06-15 23:29:30 -03:00
Daniel Wykerd
2cbb0179ae fix: Innertube.download 2022-06-15 23:29:30 -03:00
Daniel Wykerd
b594dad510 fix: missing import 2022-06-15 23:29:30 -03:00
Daniel Wykerd
6d7609c32a feat: download video directly from VideoInfo
As suggested in #45, this also implements a new "best" and
"bestefficiency" format selector.
2022-06-15 23:29:30 -03:00
LuanRT
75e0453f69 fix: stringify ChipCloudChip text
This is required in order for `selectFilter()` to work properly
2022-06-15 19:55:45 -03:00
LuanRT
f6af3faa41 feat: add ThumbnailOverlayInlineUnplayable renderer 2022-06-15 19:36:44 -03:00
LuanRT
3458bb422a feat: add ThumbnailOverlayEndorsement, #75 2022-06-15 19:22:52 -03:00
LuanRT
521029de52 fix: ignore DisplayAd renderer 2022-06-15 19:14:02 -03:00
Daniel Wykerd
4a102878d8 refactor!: feature complete contents parser
* feat: allow setting search params to custom value

This is useful for getting results other than videos, like playlists and
channels.

* feat: add initial parsers for common renderers

* feat: artist search renderers

Added common renderers used when searching artists

* refactor: snake_case

* feat: channel home page renderers

* feat: parsers for more channel tabs

These are needed for channel tabs: Videos, Playlists, Community,
Channels

Additionally, do not merely return text as string, since they may
include links which may be navigated to

* feat: channel full metadata

* feat: renderers for playlists

* refactor!: Actions.browse

Channels should be viewable when not logged in, also added 'navigation'
type for use in NagivationEndpoint in the future.

* feat: home feed parsers

* feat: watch page renderers

* feat: start implementing HomeFeed API

The HomeFeed class remains compatible with the existing API

* feat: generate types using tsc and jsdoc

* feat: browse continuations from navigationEndpoint

* fix: Actions moved to session

This follows commit 1bfe2676d8

* fix: add more typescript config

* chore: use correct spaces and quotes

* feat: Trending API

* feat: reimplement existing channel API

* feat: add base video feed class

* feat: get channel videos

* feat: channel playlists

* feat: get channel community posts

* feat: get channels from channel

* feat: get channel about page data

* feat: add missing channel parsers

this commit also adds regenerated types I've neglected to push

* feat: initial playlist reimplementation

* feat: complete playlist reimplementation

* refactor: change InnertubeError to ES6 class

* fix: some unresolved types

* chore: update types

* feat: wip video details

* feat: get music tracks in video

Possibly an implementation for issue #48

* refactor:  merge parsers (wip)

This is a work in progress.

* fix: add pnpm to ignore

* fix: merge issues

* fix: merge Video and VideoInfo

VideoInfo should be working again.
Also remove the old parsers.

* feat: set matching in Simplify

Still looking into removing Simplify

* fix: ContinuationItem

This `call` method allows for traversal of continuations with the Simplify API
but may be removed in the future

* fix: optionally returned data

* revert: replace ContinuationItem with main

* feat(parser): contents memoization by classname

* feat(channel): working without Simplify

* feat(feed): working continuations

* fix: liniting issues

* feat(feed): filterable feed for home

* feat(feed): tabbed feed for trending & channel

* refactor: remove Simplify completely

* chore: lint

* refactor: alias `items` with `contents`

* refactor: `Search` to extend `Feed`

* fix: Search working

Also added MenuServiceItemDownload

* refactor: move `Channel` and `Playlist`

* fix: pass all tests

* fix: linting errors
2022-06-15 18:31:34 -03:00
LuanRT
43470efb6e chore: remove unused vars - x2 2022-06-15 18:09:25 -03:00
LuanRT
0067ccd438 chore: remove unused vars 2022-06-15 16:49:25 -03:00
LuanRT
62811bd8f1 feat: implement SecondarySearchContainer renderer 2022-06-15 16:46:05 -03:00
LuanRT
71309a0788 fix: oopsie 2022-06-14 20:10:12 -03:00
LuanRT
7e6f944a4b feat: wrap format arrays around proxy trap
This allows users to easily find a format.

Ex:
```js
info.streaming_data.adaptive_formats.get({ quality_label: "720p" });
```
2022-06-14 19:42:44 -03:00
LuanRT
3d0b217743 fix: do not return null if a renderer is not found
This caused the parser to return null when ads or renderers that are not implemented are present.
2022-06-14 17:54:31 -03:00
LuanRT
3c98244c3b fix: refinement cards getter returning undefined 2022-06-14 17:50:12 -03:00
LuanRT
20600fcc04 chore: start docs for v2.0.0 2022-06-14 15:55:56 -03:00
LuanRT
564a5deaec chore: update workflows 2022-06-14 15:30:11 -03:00
LuanRT
54a50d5704 chore(docs): remove unneeded tags 2022-06-14 15:08:37 -03:00
LuanRT
49688a0ad6 chore: fix invalid jsdoc comments 2022-06-14 15:03:45 -03:00
LuanRT
040b382590 chore: use eslint to validate jsdoc 2022-06-14 15:03:05 -03:00
LuanRT
60b67a399c chore: update types 2022-06-14 15:02:28 -03:00
LuanRT
3f22a44ba9 feat: accurately emulate like/dislike button clicks 2022-06-13 17:33:39 -03:00
LuanRT
6aa30648fe chore: update type definitions 2022-06-13 16:23:49 -03:00
LuanRT
5f08be7991 feat: add support for retrieving YouTube Music's home feed 2022-06-13 07:38:49 -03:00
LuanRT
79d6b84dda chore: update types 2022-06-13 07:37:01 -03:00
LuanRT
7142a63b1d feat(ytmusic): add support for retrieving up next 2022-06-13 05:20:56 -03:00
LuanRT
5fd9f7ea83 chore: regenerate types 2022-06-13 05:19:31 -03:00
LuanRT
ee71e6a55f chore: remove unneeded code 2022-06-13 04:26:32 -03:00
LuanRT
b6a898f733 feat: add full support for refinement cards 2022-06-13 04:20:49 -03:00
LuanRT
797c545b80 chore: update type definitions 2022-06-13 04:19:20 -03:00
LuanRT
b3da6b11f8 chore: use null as default value 2022-06-11 08:09:26 -03:00
LuanRT
81bbbaebe2 fix: isn't always available 2022-06-11 08:06:50 -03:00
LuanRT
2254b69670 feat: add support for retrieving YTMusic “related” tab
+ finish lyrics parser and implement all needed YouTube Music renderers
2022-06-11 08:00:58 -03:00
LuanRT
a7ee98820a chore: update type definitions 2022-06-10 17:16:58 -03:00
LuanRT
c7474d7087 feat: add music search filters protobuf message
This allows users to choose filters they want without having to rely on the `selectFilter()` method.
2022-06-10 17:12:06 -03:00
LuanRT
d167a0b807 feat: add support for music search filters 2022-06-10 15:07:23 -03:00
LuanRT
95f713ff53 chore: update type definitions 2022-06-10 15:06:27 -03:00
LuanRT
53965630b7 dev: check if renderer should be ignore before parsing
Will mostly be used to ignore ad renderers.
2022-06-10 04:32:24 -03:00
LuanRT
9840acc63d feat: add support for retrieving watch next feed continuation 2022-06-10 03:57:05 -03:00
LuanRT
1676b11b0e chore: fix typos 2022-06-10 03:37:36 -03:00
LuanRT
afa39753d5 chore: add jsdoc comments to selectFilter method 2022-06-10 03:34:55 -03:00
LuanRT
659df51115 feat(VideoInfo): add support for selecting feed filters 2022-06-10 03:00:25 -03:00
LuanRT
dab89545fe chore: remove unused vars 2022-06-10 01:56:22 -03:00
LuanRT
73de36b946 feat: add merchandise parser 2022-06-10 01:50:21 -03:00
LuanRT
049fd16aab docs: update jsdoc comments 2022-06-09 15:19:46 -03:00
LuanRT
bcaa02f10c chore: update parser's readme 2022-06-09 15:02:28 -03:00
LuanRT
153238aefc dev: finish YouTube Music search parsers 2022-06-09 14:33:26 -03:00
LuanRT
b2014c80f4 dev: create ytmusic class
In future versions anything related to YouTube Music will be implemented here. The main Innertube class will expose it to users as `Innertube#music`.
2022-06-08 20:21:27 -03:00
LuanRT
018092eb78 chore: update type definitions 2022-06-08 20:12:48 -03:00
LuanRT
4ee6ec0d20 refactor: move data access code to /parser 2022-06-08 20:11:05 -03:00
LuanRT
cbac2e1c81 chore: remove unneeded param 2022-06-07 06:03:14 -03:00
LuanRT
fc191ae3d9 chore: remove unneeded super() 2022-06-07 06:00:48 -03:00
LuanRT
0661563656 feat: implement chip cloud & compact renderers 2022-06-07 05:57:28 -03:00
LuanRT
2c3f37191d fix: comments entry point teaser_content always N/A 2022-06-07 03:02:39 -03:00
LuanRT
4f7de3cc50 feat: add support for captions 2022-06-07 02:52:01 -03:00
LuanRT
5ec2a5512e chore: update funding.yml 2022-06-06 21:27:02 -03:00
LuanRT
ebbfb86600 test: a search now returns more than 20 results 2022-06-06 17:28:42 -03:00
LuanRT
07b83a823c feat: finish youtube search parser
The library is now able to parser everything from a search.
2022-06-06 17:19:24 -03:00
LuanRT
688fd55117 chore: add more info to parser's readme 2022-06-06 05:27:07 -03:00
LuanRT
87534c6489 chore(docs): update parser's readme 2022-06-06 04:53:06 -03:00
LuanRT
12618c1a0b chore: fix typo 2022-06-06 04:40:19 -03:00
LuanRT
55fd4e8143 chore: update type definitions 2022-06-06 04:20:28 -03:00
LuanRT
359020193b dev: start parser refactor on the main codebase, see #65 and #44
Things were getting a bit complicated and slow with the old parser so I decided to continue #44's work on the main codebase.
2022-06-06 04:19:14 -03:00
xrip
0b4853cb81 Access axios instance via this.#axios instead of getter 2022-05-31 16:45:56 -03:00
xrip
4ad5a5da64 Access axios instance via this.#axios instead of getter 2022-05-31 16:45:56 -03:00
xrip
f05270daee Share axios instance between modules.
This allows to use axios with http(s) and socks proxies via http(s)Agent and proxy settings.
2022-05-31 16:45:56 -03:00
LuanRT
4ccb4b07b7 chore: update proto 2022-05-30 17:05:33 -03:00
LuanRT
71c4b16654 chore: add notification/record_interactions endpoint 2022-05-30 17:03:38 -03:00
Shubham Parihar
82e8620a77 docs: fix sample code for download example 2022-05-29 15:15:39 -03:00
LuanRT
91dc854668 chore(docs): add getTimeWatched() example 2022-05-28 05:09:17 -03:00
LuanRT
f0565ec924 fix(package): add missing comma 2022-05-28 04:48:47 -03:00
LuanRT
15437e3937 chore(release): v1.4.3
- `Innertube#actions` and `Innertube#oauth` are now public classes so power users can have more control over the instance.
- Implemented all endpoints reverse engineered from the YouTube APK.
- The player script is now cached in the OS tmp folder to avoid permission problems.
- Added support for almost all YouTube search filters.
- Added support for editing channel name and description.
- Added support for retrieving Time Watched and basic channel analytics.
- Added support for comment translation.
- Typings are now generated directly from jsdocs.
- The initial Innertube configuration is now extracted from `/sw.js_data` and the visitor data is generated by the library.
- Refactored the entire library to improve maintainability and performance.
2022-05-28 04:46:30 -03:00
LuanRT
c7c0ac8b54 chore(docs): add examples for editing channel name and description 2022-05-28 04:02:03 -03:00
LuanRT
1e23cdb510 chore: fix typos 2022-05-27 17:28:58 -03:00
LuanRT
a85e9ef667 refactor!: welp, a lot of stuff
- Use the OS temp folder to cache the player, closes #57.
- Added support for editing channel name, closes #40.
- Added support for editing channel description.
- Added support for retrieving basic channel analytics, closes #54.
- Moved `Innertube#getAccountInfo()` to `Innertube#account`, and renamed it to `getInfo()`.
- `getInfo()` is now able to return email, channel id, etc.
- Improved jsdoc.
2022-05-27 08:17:16 -03:00
LuanRT
865b6870a1 refactor!: change getSearchSuggestions response schema 2022-05-27 07:35:00 -03:00
LuanRT
7284425618 chore: remove unneeded code 2022-05-25 04:03:05 -03:00
LuanRT
05f74fe004 feat: implement get_user_mention_suggestions endpoint 2022-05-25 03:56:57 -03:00
LuanRT
864f10f2e9 feat: implement geo/place_autocomplete endpoint
Found this while decompiling the YouTube APK. It is basically Google's Place Autocomplete API, but tweaked for Innertube.
2022-05-25 03:50:34 -03:00
LuanRT
369e1048d1 feat: implement /thumbnails endpoint 2022-05-25 02:29:55 -03:00
LuanRT
b1cf5d33b8 feat: implement channel management endpoints, #40 2022-05-25 01:57:54 -03:00
LuanRT
19008e126d chore: update tests 2022-05-24 06:37:27 -03:00
LuanRT
c525163f28 chore: update type definitions 2022-05-24 06:20:56 -03:00
LuanRT
155dc9bd15 refactor!: change how requests are handled 2022-05-24 06:19:13 -03:00
LuanRT
5560ba3ce4 chore: rephrase comment 2022-05-19 05:14:38 -03:00
LuanRT
6aaf9c70b9 refactor: use /sw.js_data to retrieve initial session data
Seems like the `/sw.js` service worker endpoint has a few peculiarities, see #55
2022-05-19 05:02:22 -03:00
LuanRT
e0c7496e37 style(tests): use single quotes 2022-05-18 07:38:46 -03:00
LuanRT
fa79e5cad2 fix: add default function to obj literals to avoid unexpected errors 2022-05-18 06:24:03 -03:00
LuanRT
98a2b49395 chore: update .eslintignore 2022-05-18 06:01:07 -03:00
LuanRT
17978193d0 chore: update type definitions 2022-05-18 05:58:02 -03:00
LuanRT
13f571a6dc chore: update workflows 2022-05-18 05:57:15 -03:00
LuanRT
9f3f8ad820 style: format code 2022-05-18 05:56:28 -03:00
LuanRT
2ba7a5c64e chore: update dev dependencies 2022-05-18 05:54:05 -03:00
LuanRT
d7d1c96d8c chore: use jest for tests 2022-05-18 05:53:09 -03:00
LuanRT
0219c075c7 chore: add linter 2022-05-18 05:51:54 -03:00
LuanRT
759351c38e feat: add basic channel analytics protobuf message 2022-05-16 15:47:15 -03:00
LuanRT
6312e97f95 chore: use timestamp in seconds for visitorData
YouTube also accepts timestamps in milliseconds, but since all clients generate visitorData with timestamps in seconds then the library should do the same.
2022-05-15 21:49:28 -03:00
LuanRT
c60babcf25 chore: update typings 2022-05-15 18:46:52 -03:00
LuanRT
c48cfcd8a0 chore(docs): add search filters examples 2022-05-15 16:13:54 -03:00
LuanRT
594202d61d chore(package): fix repo url 2022-05-12 18:05:57 -03:00
LuanRT
7a5490452a chore: remove uneeded jsdoc param 2022-05-12 14:47:03 -03:00
LuanRT
b4bb44b797 fix: add missing await key, #51 2022-05-11 06:29:46 -03:00
LuanRT
43f3c3fbf8 feat: add type search filter
The `no_filters` protobuf message was also implemented so playlists, channels, etc can be retrived from a search without any filter. #44
2022-05-11 06:14:25 -03:00
LuanRT
b48ae0b8d3 chore: update search filter protobuf message 2022-05-11 06:09:41 -03:00
LuanRT
8cf3e67f79 chore: fix getTrending() jsdoc, #50 2022-05-11 03:11:43 -03:00
LuanRT
ffa243bc07 chore: update type definitions 2022-05-09 18:47:17 -03:00
LuanRT
a08580eeee chore(docs): rephrase 2022-05-09 18:43:38 -03:00
LuanRT
039ebb7c0c chore(docs): remove unneeded stuff 2022-05-09 18:37:23 -03:00
LuanRT
46a385aa06 chore: fix major bugs and improve error handling
Seems like some methods weren't working due to a typo in the browseId, this commit should fix it. Also, additional checks were added so unexpected errors aren't thrown.
2022-05-09 18:30:22 -03:00
LuanRT
f656ccd690 chore: remove unneeded code 2022-05-09 15:15:28 -03:00
LuanRT
ddd276d99f chore: update .gitignore 2022-05-08 22:59:03 -03:00
LuanRT
5fbeaeabb6 chore: update Utils.js jsdoc 2022-05-08 22:58:41 -03:00
LuanRT
18e62f6ff8 chore: rename variable 2022-05-08 22:35:58 -03:00
LuanRT
6235985871 fix: use polling interval provided by the OAuth server 2022-05-08 22:34:40 -03:00
LuanRT
4eef0ddab0 chore: update jsdoc 2022-05-08 21:51:16 -03:00
LuanRT
6127690b4c docs: oops, forgot the hyperlink 2022-05-08 05:59:21 -03:00
LuanRT
b6cfdb733c feat: generate types using jsdoc, #50 2022-05-08 05:56:33 -03:00
LuanRT
b565213f11 docs: fix typos and reword some stuff 2022-05-08 05:53:05 -03:00
LuanRT
a5c9c9d863 feat: add support for comment translation 2022-05-06 17:50:33 -03:00
LuanRT
cf95d82d3e chore: update comment action protobuf schemas 2022-05-06 17:49:28 -03:00
LuanRT
00e0131672 docs: add git to installation instructions 2022-05-06 02:12:39 -03:00
LuanRT
2315306d9f chore: oops 2022-05-05 16:23:46 -03:00
LuanRT
1dfd4b6263 chore: add more metadata to the error class 2022-05-05 16:21:43 -03:00
LuanRT
b0a861dec8 refactor: generate sessions manually
Session generation has been moved to `core/SessionBuilder.js`, which retrieves & generates all the required data to create a valid session. This should also decrease initialization time by over 600 milliseconds!
2022-05-05 04:33:24 -03:00
LuanRT
4943685e57 refactor: simplify the player class 2022-05-05 04:17:11 -03:00
LuanRT
b773f5668c feat: add visitor data protobuf schema 2022-05-05 04:13:46 -03:00
LuanRT
4fd7371cf3 chore: update tests 2022-05-05 04:12:41 -03:00
LuanRT
16bb879689 chore: use prettyPrint parameter to reduce response sizes 2022-05-02 21:15:36 -03:00
LuanRT
a852cd22c8 chore: generate cpn for videoplayback urls 2022-05-02 21:05:17 -03:00
LuanRT
90bb3e20c0 feat: implement sound search endpoint 2022-05-02 05:07:11 -03:00
LuanRT
eab40c0034 chore: move getTimeWatched() placeholder to Innertube.account 2022-05-02 03:54:14 -03:00
LuanRT
19f7336a48 chore: add jsdoc for debug mode option 2022-05-02 02:10:11 -03:00
LuanRT
75895e5492 chore: update deciphers jsdoc 2022-05-02 01:49:37 -03:00
LuanRT
0cdfac1812 feat: add sound info protobuf schema and remove required keys, #38 2022-05-02 00:22:22 -03:00
LuanRT
446966fb2d chore(docs): add contributors list 2022-05-01 19:50:24 -03:00
LuanRT
29897981f0 feat: finalize protobuf encoder for comment translations 2022-05-01 17:49:23 -03:00
LuanRT
7e8a517de9 chore: add .gitignore file 2022-05-01 17:14:52 -03:00
LuanRT
a8b9487b58 feat: add comment translation protobuf schema 2022-05-01 17:00:56 -03:00
LuanRT
80a338e5ff chore: update compiled proto messages 2022-05-01 03:48:18 -03:00
LuanRT
e2ca022a47 chore: add jsdoc to protobuf encoders 2022-05-01 03:16:45 -03:00
luan.lrt4@gmail.com
2ebcd49f02 chore: remove unneeded async key 2022-05-01 00:14:18 -03:00
luan.lrt4@gmail.com
98a62c31da chore: remove unneeded code 2022-04-30 23:39:52 -03:00
luan.lrt4@gmail.com
1bfe2676d8 refactor!: handle all request errors in Request.js and add debug mode 2022-04-30 23:16:17 -03:00
luan.lrt4@gmail.com
4db0a0358f fix: remove unneeded if statement, #43 2022-04-29 18:49:44 -03:00
luan.lrt4@gmail.com
6bdccb89e5 chore: update protobuf messages 2022-04-28 03:12:10 -03:00
luan.lrt4@gmail.com
bbfecdb015 chore(docs): update badge 2022-04-28 01:52:41 -03:00
luan.lrt4@gmail.com
f79d4b635d feat: full support for playlist management, closes #36 2022-04-26 04:27:03 -03:00
luan.lrt4@gmail.com
283c06e64f chore: remove unneeded semicolon 2022-04-26 04:05:02 -03:00
luan.lrt4@gmail.com
5c572dba66 chore(docs): update badges 2022-04-26 03:52:29 -03:00
luan.lrt4@gmail.com
aa943a46a8 chore: update workflows 2022-04-25 02:44:54 -03:00
luan.lrt4@gmail.com
d634892b01 chore: update tests 2022-04-24 22:58:29 -03:00
luan.lrt4@gmail.com
2010714f50 fix: uncaught exception when retrieving private playlists 2022-04-24 22:52:21 -03:00
luan.lrt4@gmail.com
c6c96fd223 chore(docs): rephrasing 2022-04-22 16:03:04 -03:00
luan.lrt4@gmail.com
db41fa40d2 chore: bump version to 1.4.2 2022-04-22 00:53:05 -03:00
luan.lrt4@gmail.com
02ece1ddda chore: fix typo 2022-04-22 00:32:43 -03:00
luan.lrt4@gmail.com
b175e02f6d chore: oops 2022-04-22 00:27:03 -03:00
luan.lrt4@gmail.com
d3394f846a feat: add support for reporting comments and add comments sorting option 2022-04-22 00:22:50 -03:00
luan.lrt4@gmail.com
07b73ab78d chore: remove unneeded code 2022-04-20 06:19:36 -03:00
luan.lrt4@gmail.com
d743b5a088 refactor: use a single axios instance and remove redundant code 2022-04-20 06:18:07 -03:00
luan.lrt4@gmail.com
bb206c044c chore(tests): update signature decipher path 2022-04-20 03:55:14 -03:00
luan.lrt4@gmail.com
d48065405d chore: use compiled protobuf schemas to reduce dependency footprint 2022-04-20 03:52:44 -03:00
luan.lrt4@gmail.com
dbc8b62ba2 feat: add option to change geolocation & fix minor bugs, closes #34 2022-04-19 05:35:11 -03:00
luan.lrt4@gmail.com
e32981728b chore(release): add support for trending content and release v1.4.1 2022-04-17 22:27:42 -03:00
luan.lrt4@gmail.com
7b33dcbb79 chore: fix typo 2022-04-16 23:04:47 -03:00
LuanRT
4c6bf49bbe chore(docs): add signOut() example 2022-04-16 22:49:07 -03:00
luan.lrt4@gmail.com
4bbc2d50f4 refactor!: move everything that needs parsing to parser and improve oauth system 2022-04-16 22:08:01 -03:00
luan.lrt4@gmail.com
440d80063d chore: update typings 2022-04-16 22:02:17 -03:00
luan.lrt4@gmail.com
c49147523a chore: update tests 2022-04-16 21:20:21 -03:00
luan.lrt4@gmail.com
e221c79448 chore: move type definitions to its own folder 2022-04-15 14:43:56 -03:00
LuanRT
291d04e703 chore: add type definitions (WIP) 2022-04-15 13:52:25 -03:00
luan.lrt4@gmail.com
12baec0b0d feat: method to bulk add videos to a playlist 2022-04-15 05:59:44 -03:00
luan.lrt4@gmail.com
b793c61fd8 chore: oops 2022-04-15 05:28:12 -03:00
luan.lrt4@gmail.com
b9e15b5fbd feat: add support for playlist creation/deletion 2022-04-15 05:25:52 -03:00
luan.lrt4@gmail.com
d0c54f2b8b chore(docs): remove whitespace 2022-04-15 05:21:49 -03:00
Vorticalbox
6ff984df66 remove: unneeded comment
removed left over comment i added when writing this
2022-04-15 08:54:55 +01:00
Vorticalbox
4fa2e5c127 Create index.d.ts 2022-04-15 08:49:07 +01:00
luan.lrt4@gmail.com
725f186bd9 chore: add YouTube Studio api url (WIP) 2022-04-15 01:00:09 -03:00
luan.lrt4@gmail.com
07340931a0 chore(tests): use results from ytmusic 2022-04-13 18:56:19 -03:00
luan.lrt4@gmail.com
46d62bf83f chore: add more tests for better coverage 2022-04-13 18:50:23 -03:00
luan.lrt4@gmail.com
c28da62ec1 fix: ytmusic search suggestions not working, closes #20 2022-04-13 18:30:52 -03:00
luan.lrt4@gmail.com
c7fc18b516 feat (ytmusic): add support for singles in top result 2022-04-13 18:07:28 -03:00
luan.lrt4@gmail.com
7230a2d927 chore: fix typos 2022-04-13 01:51:03 -03:00
luan.lrt4@gmail.com
924693349c chore: remove unneeded file 2022-04-13 01:48:33 -03:00
luan.lrt4@gmail.com
1ab302319d refactor!: rewrite parser and refactor project structure, closes #19 2022-04-13 01:47:57 -03:00
luan.lrt4@gmail.com
bbc1d0135b deps: add new dependency 2022-04-04 13:59:34 -03:00
luan.lrt4@gmail.com
9c1e34c9ab feat: implement pagination, refactor some methods & better error handling 2022-04-04 13:56:22 -03:00
luan.lrt4@gmail.com
c5eea2b4ff feat: implement pagination for all endpoints 2022-04-04 13:52:59 -03:00
luan.lrt4@gmail.com
60130f4d0f refactor: add utility to access deep object properties 2022-04-04 13:51:27 -03:00
luan.lrt4@gmail.com
5090c572d5 chore(release): v1.3.8 2022-03-30 23:52:28 -03:00
luan.lrt4@gmail.com
c9c72d0f31 feat: add support for comment replies, like and dislike 2022-03-30 23:31:11 -03:00
luan.lrt4@gmail.com
7635f49191 chore: add comment reply/action prototbuf messages 2022-03-30 14:33:22 -03:00
luan.lrt4@gmail.com
c932e65dad chore: simplify livechat logic and fix yt search suggestions 2022-03-28 14:18:49 -03:00
luan.lrt4@gmail.com
23717aab11 chore: rephrase comment 2022-03-26 05:42:53 -03:00
luan.lrt4@gmail.com
85df28a7fb feat: add support for channels (WIP) 2022-03-26 05:35:16 -03:00
luan.lrt4@gmail.com
9f4970b3ee refactor: separate protobuf stuff from utilities 2022-03-26 05:33:49 -03:00
luan.lrt4@gmail.com
82bbc715ff fix: playlists and home feed should work when logged out 2022-03-23 03:18:40 -03:00
luan.lrt4@gmail.com
3ec111212c chore(docs): rephrase 2022-03-23 00:45:59 -03:00
luan.lrt4@gmail.com
7ca4b2bb45 chore(release): v1.3.6 2022-03-23 00:43:09 -03:00
luan.lrt4@gmail.com
8d411f25c8 fix: age restricted videos causing uncaught exceptions when logged out 2022-03-23 00:32:51 -03:00
luan.lrt4@gmail.com
80fe969917 refactor: use axios instances to simplify logic & improve code readability 2022-03-22 23:35:39 -03:00
luan.lrt4@gmail.com
13c94fbb8a chore: rephrase comment 2022-03-22 09:36:00 -03:00
luan.lrt4@gmail.com
60ce869054 fix: welp, let's try again 2022-03-22 09:33:08 -03:00
luan.lrt4@gmail.com
1268ac83a6 chore: use optional chaining, bleh 2022-03-22 09:18:52 -03:00
luan.lrt4@gmail.com
5e588d0db5 refactor: use continuation requests for video data 2022-03-22 09:10:25 -03:00
luan.lrt4@gmail.com
8b37bd99b1 chore: add note regarding getVideoInfo() 2022-03-22 05:51:55 -03:00
luan.lrt4@gmail.com
08741de831 fix: oops, wrong param 2022-03-22 05:50:07 -03:00
luan.lrt4@gmail.com
574a595a01 chore: remove unneeded endpoint var 2022-03-22 04:09:32 -03:00
luan.lrt4@gmail.com
16928ee71b chore: update metadata keys 2022-03-21 22:41:38 -03:00
luan.lrt4@gmail.com
de6283080b feat: return comment count in getDetails() 2022-03-21 22:39:41 -03:00
luan.lrt4@gmail.com
23ab8bca4d chore: improve parsing 2022-03-21 19:13:29 -03:00
luan.lrt4@gmail.com
068b86b410 fix: parsing error if streaming data is not available 2022-03-18 17:13:42 -03:00
LuanRT
0b001c0956 fix: getHomeFeed() should work when logged out 2022-03-09 04:10:03 -03:00
LuanRT
4c14662d42 chore(docs): fix typo 2022-03-09 04:07:56 -03:00
LuanRT
f1a9d5d77b chore(docs): fix typo 2022-03-07 19:56:48 -03:00
599 changed files with 43271 additions and 3999 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

9
.eslintignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.github
test/
cache/
src/proto/youtube.ts
coverage/
node_modules/
dist/
src/proto/generated/

87
.eslintrc.yml Normal file
View File

@@ -0,0 +1,87 @@
plugins:
[ '@typescript-eslint', 'eslint-plugin-tsdoc' ]
env:
commonjs: true
es2021: true
node: true
extends: [ eslint:recommended, 'plugin:@typescript-eslint/recommended' ]
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: latest
overrides:
-
files:
- '**/*.js'
rules:
'tsdoc/syntax': 'off'
rules:
max-len:
- error
-
code: 200
ignoreComments: true
ignoreTrailingComments: true
ignoreStrings: true
ignoreTemplateLiterals: true
ignoreRegExpLiterals: true
quotes: [error, single]
'@typescript-eslint/ban-types': 'off'
'tsdoc/syntax': 'warn'
'@typescript-eslint/no-explicit-any': 'off'
no-template-curly-in-string: error
no-unreachable-loop: error
no-unused-private-class-members: 'off'
no-prototype-builtins: 'off'
no-async-promise-executor: 'off'
no-case-declarations: 'off'
no-return-assign: 'off'
no-floating-decimal: error
no-implied-eval: error
arrow-spacing: error
no-invalid-this: error
no-lone-blocks: 'off'
no-new-func: error
no-new-wrappers: error
no-new: error
no-void: error
no-octal-escape: error
no-self-compare: error
no-sequences: error
no-throw-literal: error
no-unmodified-loop-condition: error
no-useless-call: error
no-useless-concat: error
no-useless-escape: error
no-useless-return: error
no-else-return: error
no-lonely-if: error
no-undef-init: error
no-unneeded-ternary: error
no-var: error
no-multi-spaces: error
no-multiple-empty-lines: ["error", { "max": 2, "maxEOF": 0 }]
no-tabs: error
no-trailing-spaces: error
brace-style: error
new-parens: error
space-infix-ops: error
template-curly-spacing: error
wrap-regex: error
capitalized-comments: error
prefer-template: error
keyword-spacing: ["error", { "before": true } ]
array-bracket-spacing: ["error", "always"]
arrow-parens: ["error", "always"]
comma-dangle: ["error", "never"]
comma-spacing: ["error", { "before": false, "after": true }]
computed-property-spacing: ["error", "never"]
func-call-spacing: ["error", "never"]
indent: ["error", 2, { "SwitchCase": 1 }]
key-spacing: ["error", { "beforeColon": false }]
semi: ["error", "always"]
operator-assignment: ["error", "always"]

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: luanrt
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,19 +0,0 @@
---
name: Feature Request
about: Use this template for requesting new features
title: "[FEATURE NAME]"
labels: enhancement
assignees:
---
## Expected Behavior
Please describe the behavior you are expecting
## Current Behavior
What is the current behavior?
## Sample Code
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.

View File

@@ -1,39 +0,0 @@
---
name: Issue Report
about: Use this template to report a problem
title: "[VERSION] [PROBLEM SUMMARY]"
labels: bug
assignees:
---
## Expected Behavior
Please describe the behavior you are expecting
## Current Behavior
What is the current behavior?
## Failure Information (for bugs)
Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
### Steps to Reproduce
Please provide detailed steps for reproducing the issue.
1. step 1
2. step 2
3. you get it...
### Failure Logs
Please include any relevant log snippets or files here.
## Checklist
- [ ] I am running the latest version
- [ ] I checked the documentation and found no answer
- [ ] I checked to make sure that this issue has not already been filed
- [ ] I'm reporting the issue to the correct repository (for multi-repository projects)
- [ ] I have provided sufficient information

View File

@@ -1,15 +0,0 @@
---
name: Question
about: Use this template to ask a question about the project
title: "[QUESTION SUMMARY]"
labels: question
assignees:
---
## Question
State your question
## Sample Code
Please include relevant code snippets or files that provide context for your question.

View File

@@ -1 +1 @@
blank_issues_enabled: false
blank_issues_enabled: true

33
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Feature request
description: Use this template to suggest new features
labels: [enhancement]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggestion
placeholder: How would it work?
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I am running the latest version.
required: true
- label: I checked the documentation and found no answer.
required: true
- label: I have searched the existing issues and made sure this is not a duplicate.
required: true
- label: I have provided sufficient information.
required: true

77
.github/ISSUE_TEMPLATE/issue.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Issue Report
title: "<version> <title>"
description: Use this template to report a problem
labels: [bug]
body:
- type: textarea
id: reproduce-steps
attributes:
label: Steps to reproduce
description: Please provide detailed steps for reproducing the issue.
placeholder: |
Example:
1. Step 1
2. Step 2
3. You get it..
validations:
required: true
- type: textarea
id: failure-logs
attributes:
label: Failure Logs
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: Current behavior
description: What is the current behavior?
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of the library are you running?
options:
- Default
- Edge
validations:
required: true
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I am running the latest version.
required: false
- label: I checked the documentation and found no answer.
required: true
- label: I have searched the existing issues and made sure this is not a duplicate.
required: true
- label: I have provided sufficient information.
required: true

33
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Question
description: Use this template to ask a question about the project
labels: [question]
body:
- type: textarea
id: question
attributes:
label: Question
placeholder: What do you want to know?
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I am running the latest version.
required: true
- label: I checked the documentation and found no answer.
required: true
- label: I have searched the existing issues and made sure this is not a duplicate.
required: true
- label: I have provided sufficient information.
required: true

53
.github/labeler_config.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
version: 1
labels:
- label: "breaking-change"
title: "^refactor!:.*"
- label: "github"
files:
- ".github/.*"
- label: "git"
files:
- ".gitignore"
- ".gitattributes"
- label: "tests"
files:
- "test/.*"
- label: "docs"
files:
- "docs/.*"
- "README.md"
- label: "parser"
files:
- "src/parser/.*"
- "src/Innertube.ts"
- label: "core"
files:
- "src/core/.*"
- label: "protobuf"
files:
- "src/proto/.*"
- label: "xsmall-diff"
size-below: 10
- label: "small-diff"
size-above: 9
size-below: 100
- label: "medium-diff"
size-above: 99
size-below: 500
- label: "large-diff"
size-above: 499
size-below: 1000
- label: "xlarge-diff"
size-above: 999

14
.github/workflows/labeler.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: Label PRs
on:
- pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: srvaroa/labeler@master
with:
config_path: .github/labeler_config.yml
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

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

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

View File

@@ -1,29 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 14.x, 15.x, 16.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 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: 6
days-before-close: 2
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

77
.gitignore vendored Normal file
View File

@@ -0,0 +1,77 @@
# YouTube player cache directory
cache/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Coverage directory used by tools like istanbul
coverage
*.lcov
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# pnpm
.pnpm-debug.log*
pnpm-lock.yaml
# Downloaded assets
*.mp4
*.webm
*.mkv
# Temporary files for testing
tmp/
# Build output
dist/
bundle/*.js.*
bundle/*.js
bundle/*.cjs
bundle/*.cjs.*
deno/
# VSCode files
.vscode/
# MacOS
.DS_Store

6
.npmignore Normal file
View File

@@ -0,0 +1,6 @@
**
src/
!dist/**
!README.md
!bundle/**

94
CHANGELOG.md Normal file
View File

@@ -0,0 +1,94 @@
# Changelog
## [3.3.0](https://github.com/LuanRT/YouTube.js/compare/v3.2.0...v3.3.0) (2023-03-09)
### Features
* **parser:** add `ConversationBar` node ([b2253df](https://github.com/LuanRT/YouTube.js/commit/b2253df8022217dc486155d8cacbf22db04dd9c2))
* **VideoInfo:** support get by endpoint + more info ([#342](https://github.com/LuanRT/YouTube.js/issues/342)) ([0d35fe0](https://github.com/LuanRT/YouTube.js/commit/0d35fe0ca5e87a877b76cbb6cf3c92843eac5a99))
### Bug Fixes
* **MultiMarkersPlayerBar:** avoid observing undefined objects ([f351770](https://github.com/LuanRT/YouTube.js/commit/f3517708ff34093a544c09d6f5f1ec806130d5cc))
* **SharedPost:** import `Menu` node directly (oops) ([3e3dc35](https://github.com/LuanRT/YouTube.js/commit/3e3dc351bb44faec87616d9b922924d14a95f29f))
* **ytmusic:** use static visitor id to avoid empty API responses ([f9754f5](https://github.com/LuanRT/YouTube.js/commit/f9754f5ac61d0f11b025f37f93783f971560268b)), closes [#279](https://github.com/LuanRT/YouTube.js/issues/279)
## [3.2.0](https://github.com/LuanRT/YouTube.js/compare/v3.1.1...v3.2.0) (2023-03-08)
### Features
* Add support for descriptive audio tracks ([#338](https://github.com/LuanRT/YouTube.js/issues/338)) ([574b67a](https://github.com/LuanRT/YouTube.js/commit/574b67a1f707a32378586dd2fe7b2f36f4ab6ddb))
* export `FormatUtils`' types ([2d774e2](https://github.com/LuanRT/YouTube.js/commit/2d774e26aae79f3d1b115e0e85c148ae80985529))
* **parser:** add `banner` to `PlaylistHeader` ([#337](https://github.com/LuanRT/YouTube.js/issues/337)) ([95033e7](https://github.com/LuanRT/YouTube.js/commit/95033e723ef912706e4d176de6b2760f017184e1))
* **parser:** SharedPost ([#332](https://github.com/LuanRT/YouTube.js/issues/332)) ([ce53ac1](https://github.com/LuanRT/YouTube.js/commit/ce53ac18435cbcb20d6d4c4ab52fd156091e7592))
* **VideoInfo:** add `game_info` and `category` ([#333](https://github.com/LuanRT/YouTube.js/issues/333)) ([214aa14](https://github.com/LuanRT/YouTube.js/commit/214aa147ce6306e37a6bf860a7bed5635db4797e))
* **YouTube/Search:** add `SearchSubMenu` node ([#340](https://github.com/LuanRT/YouTube.js/issues/340)) ([a511608](https://github.com/LuanRT/YouTube.js/commit/a511608f18b37b0d9f2c7958ed5128330fabcfa0))
* **yt:** add `getGuide()` ([#335](https://github.com/LuanRT/YouTube.js/issues/335)) ([2cc7b8b](https://github.com/LuanRT/YouTube.js/commit/2cc7b8bcd6938c7fb3af4f854a1d78b86d153873))
### Bug Fixes
* **SegmentedLikeDislikeButton:** like/dislike buttons can also be a simple `Button` ([9b2738f](https://github.com/LuanRT/YouTube.js/commit/9b2738f1285b278c3e83541857651be9a6248288))
* **YouTube:** fix warnings when retrieving members-only content ([#341](https://github.com/LuanRT/YouTube.js/issues/341)) ([95f1d40](https://github.com/LuanRT/YouTube.js/commit/95f1d4077ff3775f36967dca786139a09e2830a2))
* **ytmusic:** export search filters type ([cf8a33c](https://github.com/LuanRT/YouTube.js/commit/cf8a33c79f5432136b865d535fd0ecedc2393382))
## [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))

91
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,91 @@
# Contributing to YouTube.js
Thank you for taking the time to contribute!
The following is a set of guidelines for contributing to YouTube.js.
___
- [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
<a id="issue-1"></a>
#### Create a new issue
If you find a problem, search if an issue already exists. If a related issue doesn't exist, you can open a new issue using a relevant issue form.
<a id="issue-2"></a>
#### Solve an issue
Scan through the existing issues to find one that interests you. You can narrow down the search using labels as filters. If you find an issue to work on, you are welcome to open a PR with a fix. Documentation updates and grammar fixes are also appreciated!
<a id="changes"></a>
## Make changes
1. Fork the repository
2. Install or update to **Node.js v16**
3. Create a working branch and start with your changes!
<a id="changes-1"></a>
#### Commit your updates
Commit the changes once you're happy with them.
<a id="changes-2"></a>
#### Pull Request
When you think the code is ready for review a pull request should be created on Github. Owners of the repository will watch out for new PRs and review them in regular intervals.
- Fill the template.
- Link the PR to an issue, if you are solving one.
- Enable the checkbox to allow maintainer edits so the branch can be updated for a merge.
- Changes may be requested before a PR can be merged.
- As you update your PR and apply changes, mark each conversation as resolved.
<a id="test"></a>
#### Test
```bash
npm run test
```
<a id="lint"></a>
#### Lint
```bash
npm run lint
npm run lint:fix
```
<a id="build"></a>
#### Build
```bash
# 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
```

View File

@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

1574
README.md

File diff suppressed because it is too large Load Diff

1
bundle/browser.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
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;

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

@@ -0,0 +1,115 @@
# Account
YouTube account manager.
## API
* Account
* [.channel](#channel)
* [.getInfo()](#getinfo)
* [.getTimeWatched()](#gettimewatched)
* [.getSettings()](#getsettings)
* [.getAnalytics](#getanalytics)
<a name="channel"></a>
### channel
Channel settings.
**Returns:** `object`
<details>
<summary>Methods & Getters</summary>
<p>
- `<channel>#editName(new_name)`
- Edits the name of the channel.
- `<channel>#editDescription(new_description)`
- Edits channel description.
- `<channel>#getBasicAnalytics()`
- Alias for [`Account#getAnalytics()`](#getanalytics) — returns basic channel analytics.
</p>
</details>
<a name="getinfo"></a>
### getInfo()
Retrieves account information.
**Returns:** `Promise.<AccountInfo>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<accountinfo>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="gettimewatched"></a>
### getTimeWatched()
Retrieves time watched statistics.
**Returns:** `Promise.<TimeWatched>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<timewatched>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getsettings"></a>
### getSettings()
Retrieves YouTube settings.
**Returns:** `Promise.<Settings>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<settings>#selectSidebarItem(name)`
- Selects an item from the sidebar menu. Use `settings#sidebar_items` to see available items.
- `<settings>#getSettingOption(name)`
- Finds a setting by name and returns it. Use `settings#setting_options` to see available options.
- `<settings>#setting_options`
- Returns settings available in the page.
- `<settings>#sidebar_items`
- Returns options available in the sidebar menu.
- `<settings>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getanalytics"></a>
### getAnalytics()
Retrieves basic channel analytics.
**Returns:** `Promise.<Analytics>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<analytics>#page`
- 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

@@ -0,0 +1,108 @@
# InteractionManager
Handles direct interactions.
## API
* InteractionManager
* [.like(video_id)](#like)
* [.dislike(video_id)](#dislike)
* [.removeRating(video_id)](#removerating)
* [.subscribe(video_id)](#subscribe)
* [.unsubscribe(video_id)](#unsubscribe)
* [.comment(video_id, text)](#comment)
* [.translate(text, target_language, args?)](#translate)
* [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences)
<a name="like"></a>
### like(video_id)
Likes given video.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="dislike"></a>
### dislike(video_id)
Dislikes given video.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="removerating"></a>
### removeLike(video_id)
Remover like/dislike.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="subscribe"></a>
### subscribe(channel_id)
Subscribes to given channel.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |
<a name="unsubscribe"></a>
### unsubscribe(channel_id)
Unsubscribes from given channel.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |
<a name="comment"></a>
### comment(video_id, text)
Posts a comment on given video.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| text | `string` | Comment content |
<a name="translate"></a>
### translate(text, target_language, args?)
Translates given text using YouTube's comment translation feature.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| text | `string` | Text to be translated |
| target_language | `string` | ISO language code |
| args? | `object` | Additional arguments |
<a name="setnotificationpreferences"></a>
### setNotificationPreferences(channel_id, type)
Changes notification preferences for a given channel.
Only works with channels you are subscribed to.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |

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.

338
docs/API/music.md Normal file
View File

@@ -0,0 +1,338 @@
# YouTube Music
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
* Music
* [.getInfo(target)](#getinfo)
* [.search(query, filters?)](#search)
* [.getHomeFeed()](#gethomefeed)
* [.getExplore()](#getexplore)
* [.getLibrary()](#getlibrary)
* [.getArtist(artist_id)](#getartist)
* [.getAlbum(album_id)](#getalbum)
* [.getPlaylist(playlist_id)](#getplaylist)
* [.getLyrics(video_id)](#getlyrics)
* [.getUpNext(video_id, automix?)](#getupnext)
* [.getRelated(video_id)](#getrelated)
* [.getRecap()](#getrecap)
* [.getSearchSuggestions(query)](#getsearchsuggestions)
<a name="getinfo"></a>
### getInfo(target)
Retrieves track info.
**Returns:** `Promise.<TrackInfo>`
| Param | Type | Description |
| --- | --- | --- |
| target | `string` or `MusicTwoRowItem` | video id or list item |
<details>
<summary>Methods & Getters</summary>
<p>
- `<info>#getTab(title)`
- Retrieves contents of the given tab.
- `<info>#getUpNext(automix?)`
- Retrieves up next.
- `<info>#getRelated()`
- Retrieves related content.
- `<info>#getLyrics()`
- Retrieves song lyrics.
- `<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>
<a name="search"></a>
### search(query, filters?)
Searches on YouTube Music.
**Returns:** `Promise.<Search>`
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | Search query |
| filters? | `object` | Search filters |
<details>
<summary>Methods & Getters</summary>
<p>
- `<search>#getMore(shelf)`
- Equivalent to clicking on the shelf to load more items.
- `<search>#getContinuation()`
- Retrieves continuation, only works for individual sections or filtered results.
- `<search>#selectFilter(name)`
- Applies given filter to the search.
- `<search>#has_continuation`
- Checks if continuation is available.
- `<search>#filters`
- Returns available filters.
- `<search>#songs`
- Returns songs shelf.
- `<search>#videos`
- Returns videos shelf.
- `<search>#albums`
- Returns albums shelf.
- `<search>#artists`
- Returns artists shelf.
- `<search>#playlists`
- Returns songs shelf.
- `<search>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="gethomefeed"></a>
### getHomeFeed()
Retrieves home feed.
**Returns:** `Promise.<HomeFeed>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<homefeed>#getContinuation()`
- Retrieves continuation, only works for individual sections or filtered results.
- `<homefeed>#has_continuation`
- Checks if continuation is available.
- `<homefeed>#page`
- Returns original InnerTube response (sanitized).
- `<homefeed>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getexplore"></a>
### getExplore()
Retrieves “Explore” feed.
**Returns:** `Promise.<Explore>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<explore>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getlibrary"></a>
### getLibrary()
Retrieves library.
**Returns:** `Library`
<details>
<summary>Methods & Getters</summary>
<p>
- `<library>#applyFilter(filter)`
- Applies given filter to the library.
- `<library>#applySort(sort_by)`
- Applies given sort option to the library items.
- `<library>#getContinuation()`
- Retrieves continuation of the library items.
- `<library>#has_continuation`
- Checks if continuation is available.
- `<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>
<a name="getartist"></a>
### getArtist(artist_id)
Retrieves artist's info & content.
**Returns:** `Promise.<Artist>`
| Param | Type | Description |
| --- | --- | --- |
| artist_id | `string` | Artist id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<artist>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getalbum"></a>
### getAlbum(album_id)
Retrieves given album.
**Returns:** `Promise.<Album>`
| Param | Type | Description |
| --- | --- | --- |
| album_id | `string` | Album id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<album>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getplaylist"></a>
### getPlaylist(playlist_id)
Retrieves given playlist.
**Returns:** `Promise.<Playlist>`
| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
<details>
<summary>Methods & Getters</summary>
<p>
- `<playlist>#getRelated()`
- Retrieves related playlists.
- `<playlist>#getSuggestions()`
- Retrieves playlist suggestions.
- `<playlist>#getContinuation()`
- Retrieves continuation.
- `<playlist>#has_continuation`
- Checks if continuation is available.
- `<playlist>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getlyrics"></a>
### getLyrics(video_id)
Retrieves song lyrics.
**Returns:** `Promise.<MusicDescriptionShelf | undefined>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="getupnext"></a>
### getUpNext(video_id, automix?)
Retrieves up next content.
**Returns:** `Promise.<PlaylistPanel>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| automix? | `boolean` | if automix should be fetched |
<a name="getrelated"></a>
### getRelated(video_id)
Retrieves related content.
**Returns:** `Promise.<Array.<MusicCarouselShelf | MusicDescriptionShelf>>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="getrecap"></a>
### getRecap()
Retrieves your YouTube Music recap.
**Returns:** `Promise.<Recap>`
<details>
<summary>Methods & Getters</summary>
<p>
- `<recap>#getPlaylist()`
- Retrieves recap playlist.
- `<recap>#page`
- Returns the original InnerTube response(s), parsed and sanitized.
</p>
</details>
<a name="getsearchsuggestions"></a>
### getSearchSuggestions(query)
Retrieves search suggestions.
**Returns:** `Promise.<Array.<SearchSuggestion | HistorySuggestion>>`
| Param | Type | Description |
| --- | --- | --- |
| query | `string` | Search query |

72
docs/API/playlist.md Normal file
View File

@@ -0,0 +1,72 @@
# PlaylistManager
Playlist management class.
## API
* PlaylistManager
* [.create(title, video_ids)](#create)
* [.delete(playlist_id)](#delete)
* [.addVideos(playlist_id, video_ids)](#addvideos)
* [.removeVideos(playlist_id, video_ids)](#removevideos)
* [.moveVideo(playlist_id, moved_video_id, predecessor_video_id)](#movevideo)
<a name="create"></a>
### create(title, video_ids)
Creates a playlist.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| title | `string` | Playlist name |
| video_ids | `string[]` | array of videos |
<a name="delete"></a>
### delete(playlist_id)
Deletes given playlist.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
<a name="addvideos"></a>
### addVideos(playlist_id, video_ids)
Adds videos to given playlist.
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| video_ids | `string` | array of videos |
<a name="removevideos"></a>
### removeVideos(playlist_id, video_ids)
Removes videos from given playlist.
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| video_ids | `string` | array of videos |
<a name="movevideo"></a>
### moveVideo(playlist_id, moved_video_id, predecessor_video_id)
Moves a video to a new position within a given playlist.
**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`
| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| moved_video_id | `string` | the video to be moved |
| predecessor_video_id | `string` | the video present in the target position |

83
docs/API/session.md Normal file
View File

@@ -0,0 +1,83 @@
# Session
Represents an InnerTube session.
## API
* Session
* [.signIn(credentials?)](#signin) ⇒ `function`
* [.signOut()](#signout) ⇒ `function`
* [.key](#key) ⇒ `getter`
* [.api_version](#api_version) ⇒ `getter`
* [.client_version](#client_version) ⇒ `getter`
* [.client_name](#client_name) ⇒ `getter`
* [.context](#context) ⇒ `getter`
* [.player](#player) ⇒ `getter`
* [.lang](#lang) ⇒ `getter`
<a name="signin"></a>
### signIn(credentials?)
Signs in with given credentials.
**Returns:** `Promise<void>`
| Param | Type | Description |
| --- | --- | --- |
| credentials? | `Credentials` | OAuth credentials |
<a name="signout"></a>
### signOut()
Signs out of the current account.
**Returns:** `Promise<ActionsResponse>`
<a name="key"></a>
### key
InnerTube API key.
**Returns:** `string`
<a name="api_version"></a>
### api_version
InnerTube API version.
**Returns:** `string`
<a name="client_version"></a>
### client_version
InnerTube client version.
**Returns:** `string`
<a name="client_name"></a>
### client_name
InnerTube client name.
**Returns:** `string`
<a name="context"></a>
### context
InnerTube context.
**Returns:** `Context`
<a name="player"></a>
### player
Player script object.
**Returns:** `Player`
<a name="lang"></a>
### lang
Client language.
**Returns:** `string`

46
docs/API/studio.md Normal file
View File

@@ -0,0 +1,46 @@
# Studio
YouTube Studio class (WIP).
## API
* Studio
* [.setThumbnail(video_id, buffer)](#setthumbnail)
* [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
* [.upload(file, metadata)](#upload)
<a name="setthumbnail"></a>
### setThumbnail(video_id, buffer)
Uploads a custom thumbnail and sets it for a video.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| buffer | `Uint8Array` | Thumbnail buffer |
<a name="updatemetadata"></a>
### updateVideoMetadata(video_id, metadata)
Updates given video's metadata.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| metadata | `VideoMetadata` | Video metadata |
<a name="upload"></a>
### upload(file, metadata)
Uploads a video to YouTube.
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
| file | `BodyInit` | Video file |
| metadata | `UploadedVideoMetadata` | Video metadata |

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

@@ -0,0 +1,58 @@
# Updating the parser
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 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!
at Parser.printError (...)
at Parser.parseItem (...)
at Parser.parseArray (...) {
info: {
// renderer data, can be used as a reference to implement the renderer parser
},
date: 2022-05-22T22:16:06.831Z,
version: '2.2.3'
}
```
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
Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!
For example, say we found a new renderer named `verticalListRenderer`, to let the parser know it exists we would have to create a file with the following structure:
> `../classes/VerticalList.ts`
```ts
import Parser from '..';
import { YTNode } from '../helpers';
import type { RawNode } from '../index.js';
class VerticalList extends YTNode {
static type = 'VerticalList';
header;
contents;
constructor(data: RawNode) {
// parse the data here, ex;
this.header = Parser.parseItem(data.header);
this.contents = Parser.parseArray(data.contents);
}
}
export default VerticalList;
```
Then update the parser map:
```bash
npm run build:parser-map
```
And that's it!

49
examples/auth/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Authentication via OAuth
## Usage
Before using any methods which require authentication, you have to authenticate the session:
```js
// 'auth-pending' is fired with the info needed to sign in via OAuth.
yt.session.on('auth-pending', (data) => {
// data.verification_url contains the URL to visit to authenticate.
// data.user_code contains the code to enter on the website.
});
// 'auth' is fired once the authentication is complete
yt.session.on('auth', ({ credentials }) => {
// do something with the credentials, eg; save them in a database.
console.log('Sign in successful');
});
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
yt.session.on('update-credentials', ({ credentials }) => {
// do something with the updated credentials
});
await yt.session.signIn(/* credentials */);
```
### Cache Credentials
If you don't wish to sign in every time you start the session, you can cache the credentials. Note that this SHOULD NOT be used in production, save your credentials in a database/file instead and pass them to `Session#signIn(creds?)` when signing in.
```js
// If you use this, the next call to signIn won't fire 'auth-pending' instead just 'auth'
await yt.session.oauth.cacheCredentials();
```
**Note:** When using cached credentials, you are still required to make a call to `Session#signIn()`.
### Sign Out
The sign out method may be used to sign out of the current session. This should also remove the cached credentials.
```js
await yt.session.signOut();
// if you don't want to sign out of the current session
// and only want to delete the cached credentials, use:
await yt.session.oauth.removeCache();
```

37
examples/auth/index.js Normal file
View File

@@ -0,0 +1,37 @@
const { Innertube, UniversalCache } = require('youtubei.js');
(async () => {
const yt = await Innertube.create({
// required if you wish to use OAuth#cacheCredentials
cache: new UniversalCache(false)
});
// 'auth-pending' is fired with the info needed to sign in via OAuth.
yt.session.on('auth-pending', (data) => {
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
});
// 'auth' is fired once the authentication is complete
yt.session.on('auth', ({ credentials }) => {
console.log('Sign in successful:', credentials);
});
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
yt.session.on('update-credentials', ({ credentials }) => {
console.log('Credentials updated:', credentials);
});
// Attempt to sign in
await yt.session.signIn();
// ... do something after sign in
// You may cache the session for later use
// If you use this, the next call to signIn won't fire 'auth-pending' instead just 'auth'.
await yt.session.oauth.cacheCredentials();
// Sign out of the session
// this will also remove the cached credentials
await yt.session.signOut();
})();

View File

@@ -0,0 +1,63 @@
# Browser Usage Example
YouTube.js works in the browser!
## Usage
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
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/web.bundle.min";
const yt = await Innertube.create({
fetch: async (input, init) => {
// url
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// transform the url for use with our proxy
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
// now serialize the headers
url.searchParams.set('__headers', JSON.stringify([...headers]));
// copy over the request
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
headers.delete('user-agent');
// fetch the url
return fetch(request, init ? {
...init,
headers
} : {
headers
});
},
cache: new UniversalCache(),
});
```
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

@@ -0,0 +1,84 @@
import { serve } from 'https://deno.land/std@0.148.0/http/server.ts';
const port = 8080;
function copyHeader(headerName: string, to: Headers, from: Headers) {
const hdrVal = from.get(headerName);
if (hdrVal) {
to.set(headerName, hdrVal);
}
}
const handler = async (request: Request): Promise<Response> => {
// if options send do CORS preflight
if (request.method === 'OPTIONS') {
const response = new Response('', {
status: 200,
headers: new Headers({
'Access-Control-Allow-Origin': request.headers.get('origin') || '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers':
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range, Referer',
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Credentials': 'true',
}),
});
return response;
}
const url = new URL(request.url, `http://localhost/`);
if (!url.searchParams.has('__host')) {
return new Response(
'Request is formatted incorrectly. Please include __host in the query string.',
{ status: 400 },
);
}
// Set the URL host to the __host parameter
url.host = url.searchParams.get('__host')!;
url.protocol = 'https';
url.port = '443';
url.searchParams.delete('__host');
// Copy headers from the request to the new request
const request_headers = new Headers(
JSON.parse(url.searchParams.get('__headers') || '{}'),
);
copyHeader('range', request_headers, request.headers);
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
url.searchParams.delete('__headers');
// Make the request to YouTube
const fetchRes = await fetch(url, {
method: request.method,
headers: request_headers,
body: request.body,
});
// Construct the return headers
const headers = new Headers();
// copy content headers
copyHeader('content-length', headers, fetchRes.headers);
copyHeader('content-type', headers, fetchRes.headers);
copyHeader('content-disposition', headers, fetchRes.headers);
copyHeader('accept-ranges', headers, fetchRes.headers);
copyHeader('content-range', headers, fetchRes.headers);
// add cors headers
headers.set(
'Access-Control-Allow-Origin',
request.headers.get('origin') || '*',
);
headers.set('Access-Control-Allow-Headers', '*');
headers.set('Access-Control-Allow-Methods', '*');
headers.set('Access-Control-Allow-Credentials', 'true');
// Return the proxied response
return new Response(fetchRes.body, {
status: fetchRes.status,
headers: headers,
});
};
await serve(handler, { port });

24
examples/browser/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +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>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

@@ -0,0 +1,18 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^4.6.4",
"vite": "^3.0.0"
},
"dependencies": {
"dashjs": "^4.4.0"
}
}

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
var u=new Set(["www.youtube.com","music.youtube.com","suggestqueries.google.com","youtubei.googleapis.com","youtubei.googleapis.com","green-youtubei.sandbox.googleapis.com","release-youtubei.sandbox.googleapis.com","test-youtubei.sandbox.googleapis.com","cami-youtubei.sandbox.googleapis.com","uytfe.sandbox.google.com"]);self.addEventListener("fetch",o=>{try{let s=new URL(o.request.url).hostname;if(!u.has(s))return}catch(s){return}let e=new URL(o.request.url);e.searchParams.set("__host",e.host),e.host=e.searchParams.get("__proxy");let t=new Request(e,o.request);o.respondWith(fetch(t))});
//# sourceMappingURL=service-worker.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../browser/client/service-worker.ts"],
"sourcesContent": ["// We need to proxy requests to youtube to our own server to avoid CORS issues\n\n/// <reference lib=\"WebWorker\" />\n\n// export empty type because of tsc --isolatedModules flag\nexport type {};\ndeclare const self: ServiceWorkerGlobalScope;\n\nconst hosts = new Set([\n \"www.youtube.com\",\n \"music.youtube.com\",\n \"suggestqueries.google.com\",\n \"youtubei.googleapis.com\",\n \"youtubei.googleapis.com\",\n \"green-youtubei.sandbox.googleapis.com\",\n \"release-youtubei.sandbox.googleapis.com\",\n \"test-youtubei.sandbox.googleapis.com\",\n \"cami-youtubei.sandbox.googleapis.com\",\n \"uytfe.sandbox.google.com\"\n]);\n\nself.addEventListener('fetch', event => {\n try {\n const host = new URL(event.request.url).hostname;\n if (!hosts.has(host))\n return;\n } catch {\n return;\n }\n const url = new URL(event.request.url);\n url.searchParams.set('__host', url.host);\n url.host = url.searchParams.get('__proxy')!;\n\n // we should proxy this to our own server\n const request = new Request(url, event.request);\n\n event.respondWith(fetch(request));\n});\n"],
"mappings": ";AAQA,GAAM,GAAQ,GAAI,KAAI,CAClB,kBACA,oBACA,4BACA,0BACA,0BACA,wCACA,0CACA,uCACA,uCACA,0BACJ,CAAC,EAED,KAAK,iBAAiB,QAAS,GAAS,CACpC,GAAI,CACA,GAAM,GAAO,GAAI,KAAI,EAAM,QAAQ,GAAG,EAAE,SACxC,GAAI,CAAC,EAAM,IAAI,CAAI,EACf,MACR,OAAQ,EAAN,CACE,MACJ,CACA,GAAM,GAAO,GAAI,KAAI,EAAM,QAAQ,GAAG,EACtC,EAAI,aAAa,IAAI,SAAU,EAAI,IAAI,EACvC,EAAI,KAAO,EAAI,aAAa,IAAI,SAAS,EAGzC,GAAM,GAAU,GAAI,SAAQ,EAAK,EAAM,OAAO,EAE9C,EAAM,YAAY,MAAM,CAAO,CAAC,CACpC,CAAC",
"names": []
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,158 @@
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);
// transform the url for use with our proxy
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? 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,
input instanceof Request ? input : undefined,
);
headers.delete('user-agent');
// fetch the url
return fetch(request, init ? {
...init,
headers
} : {
headers
});
},
cache: new UniversalCache(false),
});
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
form.style.display = 'block';
showUI(false);
let player: dashjs.MediaPlayerClass | undefined;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (player) {
player.reset();
}
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 {
if (video_id_or_url.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
const endpoint = await yt.resolveURL(video_id_or_url);
if (!endpoint.payload.videoId) {
title.textContent = 'Could not resolve URL';
showUI(false);
return;
}
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);
// 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) {
title.textContent = 'An error occurred (see console)';
showUI(false);
console.error(error);
}
});
}
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

@@ -0,0 +1,90 @@
body {
display: flex;
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 {
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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "Node",
"strict": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,50 @@
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');
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('\nVideos:');
const videos = await channel.getVideos();
for (const video of videos.videos) {
console.info('Video:', video.title.toString());
}
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('\nChannels:');
const channels = await channel.getChannels();
for (const channel of channels.channels) {
console.info('Channel:', channel.author.name);
}
console.info('\nCommunity posts:');
const posts = await channel.getCommunity();
for (const post of posts.posts) {
console.info('Post:', post.content.toString().substring(0, 20) + '...');
}
})();

View File

@@ -0,0 +1,34 @@
## 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
* Comment
* [.like](#like) ⇒ `function`
* [.dislike](#dislike) ⇒ `function`
* [.reply](#comment) ⇒ `function`
* [.translate](#translate) ⇒ `function`
<a name="like"></a>
### like()
Likes the comment.
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
<a name="dislike"></a>
### dislike()
Dislikes the comment.
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
<a name="reply"></a>
### reply(text)
Creates a reply to the comment. **Note:** To create a top-level comment, use the [`Comments#comment(text)`](./README.md#comment) method.
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object }>`
<a name="translate"></a>
### translate(target_language)
Translates the comment to the given language.
**Returns:** `Promise.<{ success: boolean, status_code: number, data: object, content: string }>`

View File

@@ -0,0 +1,50 @@
# CommentThread
A `CommentThread` represents a top-level comment and its replies.
## API
* CommentThread
* [.comment](#comment) ⇒ `Comment`
* [.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`](../../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[]`](../../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>`](../../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>`](../../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

@@ -0,0 +1,65 @@
## Comments
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
## Usage
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
```js
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[]`](../../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)
Creates a top-level comment.
| Param | Type | Description |
| --- | --- | --- |
| text | `string` | Comment content |
**Returns:** `Promise<ActionsResponse>`
<a name="getc"></a>
### getContinuation()
Retrieves next batch of comment threads.
**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
Returns original InnerTube response (sanitized).
**Returns:** `ParsedResponse`
## Example
See [`index.ts`]('./index.ts').

View File

@@ -0,0 +1,45 @@
import { Innertube, UniversalCache } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const comment_section = await yt.getComments('a-rqu-hjobc');
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}`, '\n'
);
if (thread.has_replies) {
console.info('Replies:', '\n');
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}`, '\n'
);
}
try {
comment_thread = await comment_thread.getContinuation();
} catch { break; };
}
}
}
console.log('\n');
}
})();

7
examples/deno/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Deno example
Run this example with:
```
deno run --allow-net --allow-write index.ts
```

16
examples/deno/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
const yt = await Innertube.create();
const video = await yt.getInfo('dQw4w9WgXcQ');
console.log('Video title is', video.basic_info.title);
const file = await Deno.open('test.mp4', {
write: true,
create: true,
});
const stream = await video.download();
stream.pipeTo(file.writable);

View File

@@ -0,0 +1,44 @@
import { Innertube, UniversalCache, Utils } from 'youtubei.js';
import { existsSync, mkdirSync, createWriteStream } from 'fs';
(async () => {
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');
for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
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()}`;
if (!existsSync(dir)) {
mkdirSync(dir);
}
const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
for await (const chunk of Utils.streamToIterable(stream)) {
file.write(chunk);
}
console.info(`${song.id} - Done!`, '\n');
}
console.info(`Downloaded ${album.header?.song_count}!`);
})();

View File

@@ -1,92 +0,0 @@
'use strict';
const fs = require('fs');
const Innertube = require('..');
const creds_path = './yt_oauth_creds.json';
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
async function start() {
const youtube = await new Innertube();
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});
await youtube.signIn(creds);
const search = await youtube.search('Looking for life on Mars - documentary');
console.info('Search results:', search);
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
console.info('Video details:', video);
if (youtube.logged_in) {
const myNotifications = await youtube.getNotifications();
console.info('My notifications:', myNotifications);
const like = await video.like();
if (like.success) {
console.info('Video marked as liked!');
}
const dislike = await video.dislike();
if (dislike.success) {
console.info('Video marked as disliked!');
}
const removeDislikeOrLike = await video.removeLike();
if (removeDislikeOrLike.success) {
console.info('Removed the dislike/like!')
}
const myComment = await video.comment('Haha, nice!');
if (myComment.success) {
console.info('Comment successfully posted!')
}
const subscribe = await video.subscribe();
if (subscribe.success) {
console.info('Just subscribed to', video.metadata.channel_name + '!');
}
const unsubscribe = await video.unsubscribe();
if (unsubscribe.success) {
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
}
}
// Downloading videos:
const stream = youtube.download(search.videos[0].id, {
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
});
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
stream.on('start', () => {
console.info('[DOWNLOADER]', 'Starting download now!');
});
stream.on('info', (info) => {
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
});
stream.on('end', () => {
console.info('[DOWNLOADER]', 'Done!');
});
stream.on('error', (err) => console.error('[ERROR]', err));
}
start();

112
examples/livechat/README.md Normal file
View File

@@ -0,0 +1,112 @@
## Live Chat
Represents a livestream chat.
## Usage
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:
```js
const livechat = await info.getLiveChat();
```
## API
* LiveChat
* [.ev](#ev) ⇒ `EventEmitter`
* [.start](#start) ⇒ `function`
* [.stop](#stop) ⇒ `function`
* [.applyFilter](#applyfilter) ⇒ `function`
* [.getItemMenu](#getitemmenu) ⇒ `function`
* [.sendMessage](#sendmessage) ⇒ `function`
<a name="ev"></a>
### ev
Live Chat's EventEmitter.
**Events:**
- `start`
Fired when the live chat is started.
Arguments:
| Type | Description |
| --- | --- |
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
- `chat-update`
Fired when a new chat action is received.
Arguments:
| Type | Description |
| --- | --- |
| `ChatAction` | Chat action |
- `metadata-update`
Fired when the livestream's metadata is updated.
Arguments:
| Type | Description |
| --- | --- |
| `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()
Starts the Live Chat.
<a name="stop"></a>
### 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.
| Param | Type | Description |
| --- | --- | --- |
| item | `object` | Chat item |
**Returns:** `Promise<ItemMenu>`
<a name="sendmessage"></a>
### sendMessage(text)
Sends a message.
| Param | Type | Description |
| --- | --- | --- |
| text | `string` | Message content |
**Returns:** `Promise<ObservedArray<AddChatItemAction>>`
## Example
See [`index.ts`]('./index.ts').

106
examples/livechat/index.ts Normal file
View File

@@ -0,0 +1,106 @@
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(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();
livechat.on('start', (initial_data: LiveChatContinuation) => {
/**
* 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 || '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
* the live chat. All actions have a `type` property,
* including their item (if the action has an item).
*
* Below are a few examples of how this can be used.
*/
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(
`${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(
`${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:
console.debug(action);
break;
}
}
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');
}
});
livechat.on('metadata-update', (metadata: LiveMetadata) => {
console.info(`
VIEWS: ${metadata.views?.view_count.toString()}
LIKES: ${metadata.likes?.default_text}
DATE: ${metadata.date?.date_text}
`);
});
livechat.start();
})();

File diff suppressed because one or more lines are too long

26
examples/parser/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';
// Artist page response from YouTube Music
const data = readFileSync('./artist.json').toString();
const page = Parser.parseResponse(JSON.parse(data));
const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);
console.info('Header:', header);
// The parser encapsulates all arrays in a proxy object.
// A proxy intercepts access to the actual data, allowing
// the parser to add type safety and many utility methods
// that make working with InnerTube much easier.
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
throw new Error('Target tab not found');
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);

35
examples/upload/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { Innertube, UniversalCache } from 'youtubei.js';
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(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}`);
});
yt.session.on('auth', (data: any) => {
writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed in!');
});
yt.session.on('update-credentials', (data: any) => {
writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});
await yt.session.signIn(creds);
const file = readFileSync('./my_awesome_video.mp4');
const upload = await yt.studio.upload(file.buffer, {
title: 'Wow!',
description: new Date().toString(),
privacy: 'UNLISTED'
});
console.info('Done!', upload);
})();

View File

@@ -1 +0,0 @@
module.exports = require('./lib/Innertube');

17
jest.config.js Normal file
View File

@@ -0,0 +1,17 @@
export default {
projects: [
{
displayName: 'node',
roots: [ '<rootDir>/test' ],
testTimeout: 10000,
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},
moduleFileExtensions: ["ts", "tsx", "js"],
testMatch: [ '**/*.test.ts' ],
setupFiles: []
}
]
};

View File

@@ -1,467 +0,0 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {Innertube} session A valid Innertube session.
* @param {string} engagement_type Type of engagement.
* @param {object} args Engagement arguments.
* @returns {Promise.<object>} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string }
*/
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data = {
context: session.context,
target: {
videoId: args.video_id
}
};
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data = {
context: session.context,
channelIds: [args.channel_id],
params: engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'
};
break;
case 'comment/create_comment':
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.encodeCommentParams(args.video_id)
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function browse(session, action_type, args = {}) {
if (!session.logged_in && (action_type != 'lyrics' || action_type != 'music_playlist'))
throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'account_notifications':
data = {
context: session.context,
browseId: 'SPaccount_notifications'
};
break;
case 'account_privacy':
data = {
context: session.context,
browseId: 'SPaccount_privacy'
};
break;
case 'history':
data = {
context: session.context,
browseId: 'FEhistory'
}
break;
case 'home_feed':
data = {
context: session.context,
browseId: 'FEwhat_to_watch'
};
break;
case 'subscriptions_feed':
data = {
context: session.context,
browseId: 'FEsubscriptions'
};
break;
case 'lyrics':
case 'music_playlist':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context,
browseId: args.browse_id
}
break;
case 'playlist':
data = {
context: session.context,
browseId: args.browse_id
}
break;
default:
}
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Account settings endpoints.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function account(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'account/account_menu':
data = { context: session.context };
break;
case 'account/set_setting':
data = {
context: session.context,
newValue: {
boolValue: args.new_value
},
settingItemId: args.setting_item_id
}
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Accesses YouTube Music endpoints under /youtubei/v1/music/.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @todo Implement more actions.
* @returns
*/
async function music(session, action_type, args) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data;
switch (action_type) {
case 'get_search_suggestions':
data = {
context,
input: args.input || ''
};
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_MUSIC_URL}/youtubei/v1/music/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: true })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Performs searches on YouTube.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
* @param {object} args - Search arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function search(session, client, args = {}) {
if (!args.query) throw new Error('No query was provided');
let data;
switch (client) {
case 'YOUTUBE':
data = {
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
};
break;
case 'YTMUSIC':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context: context,
query: args.query
};
break;
default:
break;
}
const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's notification system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'modify_channel_preference':
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data = {
context: session.context,
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
};
break;
case 'get_notification_menu':
data = {
context: session.context,
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
};
break;
case 'get_unseen_count':
data = {
context: session.context
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function livechat(session, action_type, args = {}) {
let data;
switch (action_type) {
case 'live_chat/get_live_chat':
data = {
context: session.context,
continuation: args.ctoken
};
break;
case 'live_chat/send_message':
data = {
context: session.context,
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
clientMessageId: `ytjs-${Uuid.v4()}`,
richMessage: {
textSegments: [{ text: args.text }]
}
};
break;
case 'live_chat/get_item_context_menu':
data = {
context: session.context
};
break;
case 'live_chat/moderate':
data = {
context: session.context,
params: args.cmd_params
};
break;
case 'updated_metadata':
data = {
context: session.context,
videoId: args.video_id
};
args.continuation && (data.continuation = args.continuation);
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, message: response.message };
return { success: true, data: response.data };
}
/**
* Gets detailed data for a video.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
let response;
!args.desktop &&
(response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
}
/**
* Requests continuation for previously performed actions.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Continuation arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function getContinuation(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
if (args.video_id) {
data.videoId = args.video_id;
if (args.ytmusic) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.isAudioOnly = true;
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
} else {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
}
data.captionsRequested = false;
}
}
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Gets search suggestions.
*
* @param {Innertube} session
* @param {string} query
* @returns
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${query}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, getContinuation, getYTSearchSuggestions };

View File

@@ -1,144 +0,0 @@
'use strict';
const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE_URL: 'https://www.youtube.com',
YT_MUSIC_URL: 'https://music.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/'
},
OAUTH: {
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
MODEL_NAME: 'ytlr::',
HEADERS: {
headers: {
'accept': '*/*',
'origin': 'https://www.youtube.com',
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
'content-type': 'application/json',
'referer': `https://www.youtube.com/tv`,
'accept-language': 'en-US'
}
}
},
DEFAULT_HEADERS: (session) => {
return {
headers: {
'Cookie': session.cookie,
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
'Referer': 'https://www.google.com/',
'Accept': 'text/html',
'Accept-Language': 'en-US,en',
'Accept-Encoding': 'gzip',
'Upgrade-Insecure-Requests': 1
}
};
},
STREAM_HEADERS: {
'Accept': '*/*',
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
'Connection': 'keep-alive',
'Origin': 'https://www.youtube.com',
'Referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_REQOPTS: (info) => {
info.desktop === undefined && (info.desktop = true);
const origin = info.ytmusic && 'https://music.youtube.com' ||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
let req_opts = {
params: info.params || {},
headers: {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData || '',
'x-youtube-client-name': info.desktop ? 1 : 2,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': origin,
'origin': origin,
}
};
info.id && (req_opts.headers.referer = (info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com') + '/watch?v=' + info.id);
if (info.session.logged_in && info.desktop) {
req_opts.headers.Cookie = info.session.cookie;
req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid;
}
return req_opts;
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {
playbackContext: {
contentPlaybackContext: {
'currentUrl': '/watch?v=' + id,
'vis': 0,
'splay': false,
'autoCaptionsDefaultOn': false,
'autonavState': 'STATE_OFF',
'html5Preference': 'HTML5_PREF_WANTS',
'signatureTimestamp': sts,
'referer': 'https://www.youtube.com',
'lactMilliseconds': '-1'
}
},
context: context,
videoId: id
};
},
YTMUSIC_VERSION: '1.20211213.00.00',
METADATA_KEYS: [
'embed', 'view_count', 'average_rating',
'length_seconds', 'channel_id', 'channel_url',
'external_channel_id', 'is_live_content', 'is_family_safe',
'is_unlisted', 'is_private', 'has_ypc_metadata',
'category', 'owner_channel_name', 'publish_date',
'upload_date', 'keywords', 'available_countries',
'owner_profile_url'
],
BLACKLISTED_KEYS: [
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', 'allow_ratings', 'author'
],
ACCOUNT_SETTINGS: {
// Notifications
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
// Privacy
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
},
BASE64_DIALECT: {
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
},
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
FUNCS: {
PUSH: 'd.push(e)',
REVERSE_1: 'd.reverse()',
REVERSE_2: 'function(d){for(var',
SPLICE: 'd.length;d.splice(e,1)',
SWAP0_1: 'd[0])[0])',
SWAP0_2: 'f=d[0];d[0]',
ROTATE_1: 'reverse().forEach',
ROTATE_2: 'unshift(d.pop())',
BASE64_DIA: 'function(){for(var',
TRANSLATE_1: 'function(d,e){for(var f',
TRANSLATE_2: 'function(d,e,f){var'
}
};

View File

@@ -1,878 +0,0 @@
'use strict';
const Axios = require('axios');
const Stream = require('stream');
const OAuth = require('./OAuth');
const Utils = require('./Utils');
const Player = require('./Player');
const Parser = require('./Parser');
const NToken = require('./NToken');
const Actions = require('./Actions');
const Livechat = require('./Livechat');
const Constants = require('./Constants');
const SigDecipher = require('./Sig');
const EventEmitter = require('events');
const CancelToken = Axios.CancelToken;
class Innertube {
#player;
#retry_count;
/**
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
* @param {string} [cookie]
* @returns {Innertube}
* @constructor
*/
constructor(cookie) {
this.cookie = cookie || '';
this.#retry_count = 0;
return this.#init();
}
async #init() {
const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`);
try {
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
if (data.INNERTUBE_CONTEXT) {
this.key = data.INNERTUBE_API_KEY;
this.context = data.INNERTUBE_CONTEXT;
this.player_url = data.PLAYER_JS_URL;
this.logged_in = data.LOGGED_IN;
this.sts = data.STS;
this.context.client.hl = 'en';
this.context.client.gl = 'US';
/**
* @event auth - Fired when signing in to an account.
* @event update-credentials - Fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.#player = new Player(this);
await this.#player.init();
if (this.logged_in && this.cookie.length > 1) {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.#initMethods();
} else {
throw new Error('Could not retrieve Innertube session due to unknown reasons');
}
} catch (err) {
this.#retry_count += 1;
if (this.#retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
return this.#init();
}
return this;
}
#initMethods() {
this.account = {
info: () => this.getAccountInfo(),
settings: {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
/**
* Recommended content notifications.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
/**
* Notify about activity on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
/**
* Notify about replies to your comments.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
/**
* Notify when others mention your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
}
}
}
this.interact = {
/**
* Likes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
/**
* Diskes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
/**
* Removes a like/dislike.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
/**
* Subscribes to a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
/**
* Unsubscribes from a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
*
* @param {string} channel_id
* @param {string} type PERSONALIZED | ALL | NONE
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
};
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
async #setSetting(setting_id, type, new_value) {
const response = await Actions.browse(this, type);
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents[1]
.itemSectionRenderer.contents.find((content) => content.settingsOptionsRenderer.options)
.settingsOptionsRenderer.options;
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
const set_setting = await Actions.account(this, 'account/set_setting', { new_value, setting_item_id });
return {
success: set_setting.success,
status_code: response.status_code,
}
}
/**
* Signs-in to a google account.
*
* @param {object} auth_info
* @param {string} auth_info.access_token - Token used to sign in.
* @param {string} auth_info.refresh_token - Token used to get a new access token.
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish
* @returns {Promise.<void>}
*/
signIn(auth_info = {}) {
return new Promise(async (resolve) => {
const oauth = new OAuth(auth_info);
if (auth_info.access_token) {
if (!oauth.isTokenValid()) {
const tokens = await oauth.refreshAccessToken();
auth_info.refresh_token = tokens.credentials.refresh_token;
auth_info.access_token = tokens.credentials.access_token;
this.ev.emit('update-credentials', { credentials: tokens.credentials, status: tokens.status });
}
this.access_token = auth_info.access_token;
this.refresh_token = auth_info.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.ev.emit('auth', { credentials: data.credentials, status: data.status });
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
resolve();
} else {
this.ev.emit('auth', data);
}
});
}
});
}
/**
* Returns information about the account being used.
* @returns {Promise<{ name: string; photo: Array<object>; country: string; language: string; }>}
*/
async getAccountInfo() {
const response = await Actions.account(this, 'account/account_menu');
if (!response.success) throw new Error('Could not get account info');
const menu = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer;
return {
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
country: menu.sections[1].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.subtitle.simpleText,
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
}
}
/**
* Searches on YouTube.
*
* @param {string} query - Search query.
* @param {object} options - Search options.
* @param {string} options.client - Client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
* @param {string} options.period - Filter videos uploaded within a period, can be: any | hour | day | week | month | year
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views
* @param {string} options.duration - Filter video results by duration, can be: any | short | long
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } |
* { songs: []; videos: []; albums: []; playlists: [] }>}
*/
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
const response = await Actions.search(this, options.client, { query, options });
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
const data = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH',
query
}).parse();
return data;
}
/**
* Gets search suggestions.
*
* @param {string} input - The search query.
* @param {string} [client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<[{ text: string; bold_text: string }]>}
*/
async getSearchSuggestions(input, { client = 'YOUTUBE' }) {
if (client == 'YOUTUBE') {
const response = await Actions.getYTSearchSuggestions(this, input);
if (!response.success) throw new Error('Could not get search suggestions');
return response.data[1].map((item) => {
return {
text: item.trim(),
bold_text: response.data[0].trim()
};
});
} else if (client == 'YTMUSIC') {
const response = await Actions.music(this, 'get_search_suggestions', { input });
if (!response.success) throw new Error('Could not get search suggestions');
if (!response.data.contents) return [];
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
return contents.map((item) => {
let suggestion;
if (item.historySuggestionRenderer) {
suggestion = item.historySuggestionRenderer.suggestion;
} else {
suggestion = item.searchSuggestionRenderer.suggestion;
}
return {
text: suggestion.runs.map((run) => run.text).join('').trim(),
bold_text: suggestion.runs[0].text.trim()
};
});
}
}
/**
* Gets details for a video.
*
* @param {string} video_id - The id of the video.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
*/
async getDetails(video_id) {
if (!video_id) throw new Error('You must provide a video id');
const data = await Actions.getVideoInfo(this, { id: video_id, is_desktop: false });
const refined_data = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse();
if (refined_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id });
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, video_id);
} else {
refined_data.getLivechat = () => { };
}
} else {
refined_data.getLivechat = () => { };
}
refined_data.like = () => Actions.engage(this, 'like/like', { video_id });
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
refined_data.getComments = () => this.getComments(video_id);
refined_data.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: type || 'NONE' });
return refined_data;
}
/**
* Retrieves the lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>} Song lyrics
*/
async getLyrics(video_id) {
const data_continuation = await Actions.getContinuation(this, { video_id: video_id, ytmusic: true });
const lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId });
if (!response.data.contents.sectionListRenderer) throw new Error(response.data.contents.messageRenderer.text.runs[0].text);
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
return lyrics;
}
/**
* Parses a given playlist.
*
* @param {string} playlist_id - The id of the playlist.
* @param {object} options - { client: YOUTUBE | YTMUSIC }
* @param {string} options.client - Client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
* @returns {Promise.<
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } |
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
*/
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
const data = new Parser(this, response.data, { client: options.client, data_type: 'PLAYLIST' }).parse();
return data;
}
/**
* Gets the comments section of a video.
*
* @param {string} video_id - The id of the video.
* @param {string} [token] - Continuation token (optional).
* @return {Promise.<[{ comments: []; comment_count: string }]>
*/
async getComments(video_id, token) {
let comment_section_token;
if (!token) {
const data_continuation = await Actions.getContinuation(this, { video_id });
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
}
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
if (!response.success) throw new Error('Could not fetch comments section');
const comments_section = { comments: [] };
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
let continuation_token;
!token && (continuation_token = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token) ||
(continuation_token = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token);
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
let contents;
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
contents.forEach((thread) => {
if (!thread.commentThreadRenderer) return;
const comment = {
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
author: {
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
},
metadata: {
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount && thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText || 'N/A',
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
}
};
comments_section.comments.push(comment);
});
return comments_section;
}
/**
* Returns your watch history.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHistory() {
const response = await Actions.browse(this, 'history');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents;
const history = [];
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_items = section.itemSectionRenderer.contents;
section_items.forEach((item) => {
const content = {
id: item.videoRenderer.videoId,
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.videoRenderer.shortBylineText && item.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.videoRenderer.viewCountText && item.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.videoRenderer.thumbnail && item.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: item.videoRenderer.richThumbnail && item.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: item.videoRenderer.publishedTimeText && item.videoRenderer.publishedTimeText.simpleText || 'N/A',
duration: item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || 'N/A',
badges: item.videoRenderer.badges && item.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.videoRenderer.ownerBadges && item.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
}
};
history.push(content);
});
});
return history;
}
/**
* Returns YouTube's home feed.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHomeFeed() {
const response = await Actions.browse(this, 'home_feed');
if (!response.success) throw new Error('Could not get home feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer.contents;
return contents.map((item) => {
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
item.richItemRenderer.content || undefined;
if (content) return {
id: content.videoRenderer.videoId,
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: content.videoRenderer.shortBylineText && content.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: content.videoRenderer.viewCountText && content.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: content.videoRenderer.thumbnail && content.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: content.videoRenderer.richThumbnail && content.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: content.videoRenderer.publishedTimeText && content.videoRenderer.publishedTimeText.simpleText || 'N/A',
badges: content.videoRenderer.badges && content.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}).filter((video) => video);
}
/**
* Returns your subscription feed.
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
*/
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Error('Could not get subscriptions feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const subscriptions_feed = {};
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_contents = section.itemSectionRenderer.contents[0];
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
const key = section_contents.shelfRenderer.title.runs[0].text;
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
section_items.forEach((item) => {
const content = {
id: item.gridVideoRenderer.videoId,
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
}
};
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
});
});
return subscriptions_feed;
}
/**
* Retrieves your notifications.
* @returns {Promise.<[{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }]>}
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Error('Could not fetch notifications');
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' };
return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => {
if (!notification.notificationRenderer) return;
notification = notification.notificationRenderer;
return {
title: notification.shortMessage.simpleText,
sent_time: notification.sentTimeText.simpleText,
channel_name: notification.contextualMenu.menuRenderer.items[1].menuServiceItemRenderer.text.runs[1].text,
channel_thumbnail: notification.thumbnail.thumbnails[0],
video_thumbnail: notification.videoThumbnail.thumbnails[0],
video_url: `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}`,
read: notification.read,
notification_id: notification.notificationId,
};
}).filter((notification_block) => notification_block);
}
/**
* Returns unseen notifications count.
* @returns {Promise.<number>} unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Error('Could not get unseen notifications count');
return response.data.unseenCount;
}
/**
* Internal method to process and filter formats.
*
* @param {object} options
* @param {object} video_data
* @returns {object.<{ selected_format: {}; formats: [] }>}
*/
#chooseFormat(options, video_data) {
let formats = [];
formats = formats
.concat(video_data.streamingData.formats || [])
.concat(video_data.streamingData.adaptiveFormats || []);
formats.forEach((format) => {
format.url = format.url || format.signatureCipher || format.cipher;
if (format.signatureCipher || format.cipher) {
format.url = new SigDecipher(format.url, this.#player).decipher();
}
const url_components = new URL(format.url);
url_components.searchParams.set('cver', this.context.client.clientVersion);
url_components.searchParams.set('ratebypass', 'yes');
if (url_components.searchParams.get('n')) {
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc).transform(url_components.searchParams.get('n')));
}
format.url = url_components.toString();
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
format.has_video = !!format.qualityLabel;
delete format.cipher;
delete format.signatureCipher;
});
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
let format;
let bitrates;
let filtered_formats;
filtered_formats = ({
'video': formats.filter((format) => format.has_video && !format.has_audio),
'audio': formats.filter((format) => format.has_audio && !format.has_video),
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
if (options.type != 'videoandaudio') {
let streams;
options.type != 'audio' &&
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
streams == undefined || streams.length == 0 &&
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
bitrates = streams.map((format) => format.bitrate);
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
} else {
format = filtered_formats[0];
}
return { selected_format: format, formats };
}
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} id - The id of the video.
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
* @param {string} options.format - File format
* @returns {Promise.<{ selected_format: {}; formats: [] }>}
*/
async getStreamingData(id, options = {}) {
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
const data = await Actions.getVideoInfo(this, { id, desktop: true });
const streaming_data = this.#chooseFormat(options, data);
if (!streaming_data.selected_format) throw new Error('Could not find any suitable format.');
return streaming_data;
}
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} id - The id of the video.
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
* @param {string} options.format - File format
* @return {ReadableStream}
*/
download(id, options = {}) {
if (!id) throw new Error('Missing video id');
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
let cancel;
let cancelled = false;
const stream = new Stream.PassThrough();
Actions.getVideoInfo(this, { id, desktop: true }).then(async (video_data) => {
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
if (!video_data.streamingData)
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
if (!format)
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
stream.emit('info', { video_details, selected_format: format, formats });
if (options.type == 'videoandaudio' && !options.range) {
const response = await Axios.get(format.url, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
return stream;
} else {
stream.emit('start');
}
let downloaded_size = 0;
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
stream.emit('progress', {
size,
percentage,
chunk_size: chunk.length,
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
raw_data: {
chunk_size: chunk.length,
downloaded: downloaded_size,
size: response.headers['content-length']
}
});
});
response.data.on('error', (err) => {
cancelled &&
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
});
response.data.pipe(stream, { end: true });
} else {
const chunk_size = 1048576 * 10; // 10MB
let chunk_start = (options.range && options.range.start || 0);
let chunk_end = (options.range && options.range.end || chunk_size);
let downloaded_size = 0;
let must_end = false;
stream.emit('start');
const downloadChunk = async () => {
(chunk_end >= format.contentLength || options.range) && (must_end = true);
options.range && (format.contentLength = options.range.end);
const response = await Axios.get(`${format.url}&range=${chunk_start}-${chunk_end || ''}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
return stream;
}
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (format.contentLength / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / format.contentLength) * 100);
stream.emit('progress', {
size,
percentage,
chunk_size: chunk.length,
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
raw_data: {
chunk_size: chunk.length,
downloaded: downloaded_size,
size: response.headers['content-length']
}
});
});
response.data.on('error', (err) => {
cancelled &&
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
});
response.data.on('end', () => {
if (!must_end && !options.range) {
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
downloadChunk();
}
});
response.data.pipe(stream, { end: must_end });
};
downloadChunk();
}
});
stream.cancel = () => {
cancelled = true;
cancel();
};
return stream;
}
}
module.exports = Innertube;

View File

@@ -1,135 +0,0 @@
'use strict';
const Actions = require('./Actions');
const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
this.ctoken = token;
this.session = session;
this.video_id = video_id;
this.channel_id = channel_id;
this.message_queue = [];
this.id_cache = [];
this.poll_intervals_ms = 1000;
this.running = true;
this.#poll();
}
async #poll() {
if (!this.running) return;
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
if (!livechat.success) {
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
return await this.#poll();
}
const continuation_contents = livechat.data.continuationContents;
const action_group = continuation_contents.liveChatContinuation.actions;
this.#enqueueActionGroup(action_group);
this.message_queue.forEach((message) => {
if (this.id_cache.includes(message.id)) return;
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
this.id_cache.push(message.id);
});
this.message_queue = [];
const data = { video_id: this.video_id };
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
if (!updated_metadata.success) {
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
}
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
const metadata = updated_metadata.data.actions;
this.emit('update-metadata', {
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
view_count: {
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
}
});
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
}
#enqueueActionGroup(group) {
group.forEach((action) => {
if (!action.addChatItemAction) return; //TODO: handle different action types
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
if (!message_content) return;
const message = {
text: message_content.message.runs.map((item) => item.text).join(' '),
author: {
name: message_content.authorName && message_content.authorName.simpleText || 'N/',
channel_id: message_content.authorExternalChannelId,
profile_picture: message_content.authorPhoto.thumbnails
},
timestamp: message_content.timestampUsec,
id: message_content.id
};
this.message_queue.push(message);
});
}
async sendMessage(text) {
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
if (!message.success) return message;
const deleteMessage = async () => {
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
if (!menu.success) return menu;
const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0];
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
if (!cmd.success) return cmd;
return { success: true, status_code: cmd.status_code };
};
return {
success: true,
status_code: message.status_code,
deleteMessage: deleteMessage,
message_data: {
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
author: {
name: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName && message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText || 'N/',
channel_id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorExternalChannelId,
profile_picture: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorPhoto.thumbnails
},
timestamp: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampUsec,
id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id
}
};
}
/**
* Blocks a user.
* @todo Implement this method.
* @param {object} msg_params
*/
async blockUser(msg_params) {
throw new Error('Not implemented');
}
stop() {
this.running = false;
clearTimeout(this.livechat_poller);
}
}
module.exports = Livechat;

View File

@@ -1,142 +0,0 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
class NToken {
constructor(raw_code) {
this.raw_code = raw_code;
this.placeholders_regex = /c\[(.*?)\]=c/g;
this.calls_regex = /c\[(.*?)\]\((.+?)\)/g;
}
/**
* Solves throttling challange by transforming the n token.
*
* @param {string} n token.
* @returns {string} transformed token.
*/
transform(n) {
let n_token = n.split('');
try {
let transformations = this.#getTransformationData();
transformations = transformations.map((el) => {
if (el != null && typeof el != 'number') {
const is_reverse_base64 = el.includes('case 65:');
(({ // Identifies the transformation functions
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
}
return el;
});
// Fills all placeholders with the transformations array
const placeholder_indexes = [...this.raw_code.matchAll(this.placeholders_regex)].map((item) => parseInt(item[1]));
placeholder_indexes.forEach((i) => transformations[i] = transformations);
// Parses and emulates calls to the functions of the transformations array
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
.matchAll(this.calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
function_calls.forEach((data) => {
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
const base64_dia = (param_index[2] && transformations[param_index[2]]());
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
});
} catch (err) {
console.error(`Could not transform n-token (${n}), download may be throttled:`, err.message);
return n;
}
return n_token.join('');
}
#getFunc(el) {
return el.match(Constants.FUNCS_REGEX);
}
/**
* Takes the n-transform data, refines it, and then returns a readable json array.
* @returns {object}
*/
#getTransformationData() {
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
return JSON.parse(Utils.refineNTokenData(data));
}
/**
* Gets a base64 alphabet and uses it as a lookup table to modify n.
* @returns
*/
#translate1(arr, token, is_reverse_base64) {
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
arr.forEach(function (char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
}, token.split(''));
}
#translate2(arr, token, characters) {
let chars_length = characters.length;
arr.forEach(function (char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
}, token.split(''));
}
/**
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
* @returns {string[]}
*/
#getBase64Dia(is_reverse_base64) {
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
return characters;
}
/**
* Swaps the first element with the one at the given index.
* @returns
*/
#swap0(arr, index) {
const old_elem = arr[0];
index = (index % arr.length + arr.length) % arr.length;
arr[0] = arr[index];
arr[index] = old_elem;
}
/**
* Rotates elements of the array.
* @returns
*/
#rotate(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
}
/**
* Deletes one element at the given index.
* @returns
*/
#splice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(index, 1);
}
#reverse(arr) {
arr.reverse();
}
#push(arr, item) {
arr.push(item);
}
}
module.exports = NToken;

View File

@@ -1,200 +0,0 @@
'use strict';
const Axios = require('axios');
const Constants = require('./Constants');
const EventEmitter = require('events');
const Uuid = require('uuid');
class OAuth extends EventEmitter {
constructor(auth_info) {
super();
this.auth_info = auth_info;
this.refresh_interval = 5;
this.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
this.scope = Constants.OAUTH.SCOPE;
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
if (auth_info.access_token) return;
this.#requestAuthCode();
}
/**
* Asks the OAuth server for an auth code.
* @returns {Promise.<void>}
*/
async #requestAuthCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
const data = {
client_id: this.client_id,
scope: this.scope,
device_id: Uuid.v4(),
model_name: this.model_name
};
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth code.',
status: 'FAILED'
});
this.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
verification_url: response.data.verification_url
});
this.refresh_interval = response.data.interval;
this.#waitForAuth(response.data.device_code);
}
/**
* Waits for sign-in authorization.
*
* @param {string} device_code Client's device code.
* @returns
*/
#waitForAuth(device_code) {
const data = {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.grant_type
};
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get authentication token.',
status: 'FAILED'
});
if (response.data.error) {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestAuthCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}
/**
* Gets a new access token using a refresh token.
* @returns {object.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
*/
async refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token: this.auth_info.refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) {
this.emit('auth', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
return {
credentials: {
access_token: this.auth_info.access_token,
refresh_token: this.auth_info.refresh_token,
expires: this.auth_info.expires
},
status: 'FAILED'
};
}
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
return {
credentials: {
refresh_token: this.auth_info.refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
token_type: response.data.token_type,
status: 'SUCCESS'
};
}
/**
* Gets client identity data.
* @returns {Promise.<{ id: string; secret: string }>}
*/
async #getClientIdentity() {
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
isTokenValid() {
const timestamp = new Date(this.auth_info.expires).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
}
}
module.exports = OAuth;

View File

@@ -1,287 +0,0 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Takes raw data from the Innertube API and refines it.
* Mainly used for video data and search results, as those are more complex to parse.
*/
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.args = args;
}
parse() {
return this.args.client === 'YOUTUBE' ? ({
SEARCH: () => this.#parseVideoSearch(),
PLAYLIST: () => this.#parsePlaylist(),
VIDEO_INFO: () => this.#parseVideoInfo()
})[this.args.data_type]() : ({
SEARCH: () => this.#parseMusicSearch(),
PLAYLIST: () => this.#parseMusicPlaylist()
})[this.args.data_type]();
}
#parseVideoSearch() {
const response = {};
const contents = this.data.contents.twoColumnSearchResultsRenderer
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
.contents;
// TODO: Implement search continuation
// const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
// .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
// .continuationEndpoint.continuationCommand.token;
response.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
response.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
response.estimated_results = parseInt(this.data.estimatedResults);
response.videos = contents.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
author: video.ownerText.runs[0].text,
id: video.videoId,
url: `https://youtu.be/${video.videoId}`,
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
metadata: {
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
short_view_count_text: {
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
},
thumbnails: video.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
},
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
}
};
}).filter((video) => video);
return response;
}
#parsePlaylist() {
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
const metadata = {
title: this.data.metadata.playlistMetadataRenderer.title,
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
}
const playlist_content = this.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents[0]
.itemSectionRenderer.contents[0].playlistVideoListRenderer.contents;
const items = playlist_content.map((item) => {
if (item.playlistVideoRenderer)
return {
id: item.playlistVideoRenderer.videoId,
title: item.playlistVideoRenderer.title.runs[0].text,
author: item.playlistVideoRenderer.shortBylineText.runs[0].text,
duration: {
seconds: Utils.timeToSeconds(item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.simpleText || '0'),
simple_text: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.simpleText || 'N/A',
accessibility_label: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.accessibility.accessibilityData.label || 'N/A'
},
thumbnail: item.playlistVideoRenderer.thumbnail.thumbnails,
}
});
return {
...metadata,
items
}
}
#parseMusicSearch() {
const tabs = this.data.contents.tabbedSearchResultsRenderer.tabs;
const contents = tabs[0].tabRenderer.content.sectionListRenderer.contents;
if (contents.length <= 1)
return { songs: [], videos: [], albums: [], playlists: [] };
const songs_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Songs');
const songs = songs_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData)
return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
artist: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
album: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item); // Filters out undefined items, which are usually generated by unavailable videos.
const videos_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Videos');
const videos = videos_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData)
return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
views: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item);
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
return {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
};
});
const playlists_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Community playlists');
const playlists = playlists_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer
.playNavigationEndpoint.watchPlaylistEndpoint;
return {
id: watch_playlist_endpoint.playlistId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
channel_id: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].navigationEndpoint.browseEndpoint.browseId,
total_items: parseInt(list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text.match(/\d+/g)),
};
});
return { songs, videos, albums, playlists };
}
#parseMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details.title.runs[0].text,
description: details.description && details.description.runs.map((run) => run.text).join('') || 'N/A',
total_items: parseInt(details.secondSubtitle.runs[0].text.match(/\d+/g)),
duration: details.secondSubtitle.runs[2].text,
year: details.subtitle.runs[4].text
};
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
const items = playlist_content.map((item) => {
const item_renderer = item.musicResponsiveListItemRenderer;
const fixed_columns = item_renderer.fixedColumns;
const flex_columns = item_renderer.flexColumns;
return {
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
duration: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
thumbnail: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}).filter((item) => item.id);
return {
...metadata,
items
}
}
#parseVideoInfo() {
const desktop_v = this.args.desktop_v;
const playability_status = desktop_v && this.data.playabilityStatus ||
this.data[2].playerResponse.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
const details = desktop_v && this.data.videoDetails ||
this.data[2].playerResponse.videoDetails;
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
const streaming_data = desktop_v && this.data.streamingData ||
this.data[2].playerResponse.streamingData;
const response = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
(response.metadata[key] = entry[1]);
} else {
response[key] = entry[1];
}
});
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
(response.metadata[key] = entry[1]);
} else {
key == 'short_description' && (response.description = entry[1]) ||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (response.id = entry[1]) ||
(response[key] = entry[1]);
}
});
if (!desktop_v) {
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
}
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
return response;
}
}
module.exports = Parser;

View File

@@ -1,49 +0,0 @@
'use strict';
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
class Player {
constructor(session) {
this.session = session;
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
}
async init() {
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
this.ntoken_sc = this.#getNEncoder(player_data);
} else {
const response = await Axios.get(`${Constants.URLS.YT_BASE_URL}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
try {
// Deletes old players
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
// Caches the current player so we don't have to download it all the time.
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
} catch (err) { }
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
this.ntoken_sc = this.#getNEncoder(response.data);
}
}
#getSigDecipherCode(data) {
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
return sig_alg_sc + sig_data;
}
#getNEncoder(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
}
module.exports = Player;

View File

@@ -1,75 +0,0 @@
'use strict';
const QueryString = require('querystring');
class SigDecipher {
constructor(url, player) {
this.url = url;
this.player = player;
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
}
/**
* Deciphers signature.
*/
decipher() {
const args = QueryString.parse(this.url);
const functions = this.#getFunctions();
function splice(arr, end) {
arr.splice(0, end);
}
function swap(arr, index) {
let origArrI = arr[0];
arr[0] = arr[index % arr.length];
arr[index % arr.length] = origArrI;
}
function reverse(arr) {
arr.reverse();
}
let actions;
let signature = args.s.split('');
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
switch (actions[1]) {
case functions[0]:
reverse(signature, actions[2]);
break;
case functions[1]:
splice(signature, actions[2]);
break;
case functions[2]:
swap(signature, actions[2]);
break;
default:
}
}
const url_components = new URL(args.url);
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
return url_components.toString();
}
#getFunctions() {
let func;
let func_name = [];
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
if (func[0].includes('reverse()')) {
func_name[0] = func[1];
} else if (func[0].includes('splice')) {
func_name[1] = func[1];
} else {
func_name[2] = func[1];
}
}
return func_name;
}
}
module.exports = SigDecipher;

View File

@@ -1,193 +0,0 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
/**
* Returns a random user agent.
*
* @param {string} type - mobile | desktop
* @returns {object}
*/
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
return new UserAgent(/Android/).data;
case 'desktop':
return new UserAgent({ deviceCategory: 'desktop' }).data;
default:
}
}
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid - Sid extracted from cookies
* @returns {string}
*/
function generateSidAuth(sid) {
const youtube = 'https://www.youtube.com';
const timestamp = Math.floor(new Date().getTime() / 1000);
const input = [timestamp, sid, youtube].join(' ');
let hash = Crypto.createHash('sha1');
let data = hash.update(input, 'utf-8');
let gen_hash = data.digest('hex');
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Gets a string between two delimiters.
*
* @param {string} data - The data.
* @param {string} start_string - Start string.
* @param {string} end_string - End string.
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
const match = data.match(regex);
return match ? match[1] : undefined;
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {number} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
* @returns {string}
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
* @returns {string}
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.NotificationPreferences.encode({
channel_id,
pref_id: {
index
},
number_0: 0,
number_1: 4
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes livestream message protobuf.
*
* @param {string} channel_id
* @param {string} video_id
* @returns {string}
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.LiveMessageParams.encode({
params: {
ids: {
channel_id,
video_id
}
},
number_0: 1,
number_1: 4
});
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
/**
* Encodes comment params protobuf.
*
* @param {string} video_id
* @returns {string}
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
video_id,
params: {
index: 0
},
number: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filter protobuf
*
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year
* @param {string} duration - The duration of a video: any | short | long
* @param {string} order - The order of the search results: relevance | rating | age | views
* @returns {string}
*/
function encodeFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
const durations = { 'any': null, 'short': 1, 'long': 2 };
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
const search_filter_buff = youtube_proto.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
/**
* Turns the ntoken transform data into a valid json array
*
* @param {string} data
* @returns {string}
*/
function refineNTokenData(data) {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };

View File

@@ -1,44 +0,0 @@
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
}

11223
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,132 @@
{
"name": "youtubei.js",
"version": "1.3.6",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"main": "index.js",
"scripts": {
"test": "node test"
"version": "3.3.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
"typesVersions": {
"*": {
"agnostic": [
"./dist/src/platform/lib.d.ts"
],
"web": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle.min": [
"./dist/src/platform/lib.d.ts"
]
}
},
"author": "LuanRT",
"funding": "https://ko-fi.com/luanrt",
"license": "MIT",
"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"
],
"contributors": [
"Wykerd (https://github.com/wykerd/)",
"MasterOfBob777 (https://github.com/MasterOfBob777)",
"patrickkfkan (https://github.com/patrickkfkan)",
"akkadaska (https://github.com/akkadaska)"
],
"directories": {
"example": "examples",
"lib": "lib"
"test": "./test",
"examples": "./examples",
"dist": "./dist"
},
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
"scripts": {
"test": "npx jest --verbose",
"lint": "npx eslint ./src",
"lint:fix": "npx eslint --fix ./src",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod",
"build:parser-map": "node ./scripts/build-parser-map.cjs",
"build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
"build:esm": "npx tsc",
"build:deno": "npx cpy ./src ./deno && npx cpy ./package.json ./deno && npx replace \".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",
"prepare": "npm run build",
"watch": "npx tsc --watch"
},
"repository": {
"type": "git",
"url": "git+https//github.com/LuanRT/YouTube.js.git"
"url": "git+https://github.com/LuanRT/YouTube.js.git"
},
"license": "MIT",
"dependencies": {
"jintr": "^0.4.1",
"linkedom": "^0.14.12",
"undici": "^5.19.1"
},
"devDependencies": {
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"cpy-cli": "^4.2.0",
"esbuild": "^0.14.49",
"eslint": "^8.19.0",
"eslint-plugin-tsdoc": "^0.2.16",
"glob": "^8.0.3",
"jest": "^28.1.3",
"pbkit": "^0.0.59",
"replace": "^1.2.2",
"ts-jest": "^28.0.8",
"typescript": "^4.9.5"
},
"keywords": [
"yt",
"ytdl",
"youtube",
"youtube-dl",
"youtubedl",
"youtube-downloader",
"innertube",
"innertubeapi",
"livechat",
"downloader",
"dislike",
"search",
"comment",
"like",
"api",
"dl"
],
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
},
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
"keywords": [
"yt",
"dl",
"ytdl",
"youtube",
"youtubedl",
"youtube-dl",
"youtube-downloader",
"youtube-music",
"youtube-studio",
"innertube",
"unofficial",
"downloader",
"livechat",
"studio",
"upload",
"ytmusic",
"search",
"music",
"api"
]
}

View File

@@ -0,0 +1,59 @@
const glob = require('glob');
const fs = require('fs');
const path = require('path');
const import_list = [];
const json = [];
const misc_exports = [];
glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
.forEach((file) => {
// Trim path
const is_misc = file.includes('/misc/');
file = file.replace('../src/parser/classes/', '').replace('.js', '').replace('.ts', '');
const import_name = file.split('/').pop();
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.js';
${import_list.join('\n')}
const map: Record<string, YTNodeConstructor> = {
${json.join(',\n ')}
};
export const Misc = {
${misc_exports.join(',\n ')}
};
/**
* @param name - Name of the node to be parsed
*/
export default function GetParserByName(name: string) {
const ParserConstructor = map[name];
if (!ParserConstructor) {
const error = new Error(\`Module not found: \${name}\`);
(error as any).code = 'MODULE_NOT_FOUND';
throw error;
}
return ParserConstructor;
}
`
);

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[] };`);
})();

348
src/Innertube.ts Normal file
View File

@@ -0,0 +1,348 @@
import Session, { SessionOptions } from './core/Session.js';
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 AccountManager from './core/AccountManager.js';
import Feed from './core/Feed.js';
import InteractionManager from './core/InteractionManager.js';
import YTKids from './core/Kids.js';
import YTMusic from './core/Music.js';
import PlaylistManager from './core/PlaylistManager.js';
import YTStudio from './core/Studio.js';
import TabbedFeed from './core/TabbedFeed.js';
import HomeFeed from './parser/youtube/HomeFeed.js';
import Guide from './parser/youtube/Guide.js';
import Proto from './proto/index.js';
import Constants from './utils/Constants.js';
import type Actions from './core/Actions.js';
import type Format from './parser/classes/misc/Format.js';
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, InnertubeError, throwIfMissing } from './utils/Utils.js';
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
duration?: 'all' | 'short' | 'medium' | 'long';
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
features?: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
}
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS'
class Innertube {
session: Session;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
music: YTMusic;
studio: YTStudio;
kids: YTKids;
actions: Actions;
constructor(session: Session) {
this.session = session;
this.account = new AccountManager(this.session.actions);
this.playlist = new PlaylistManager(this.session.actions);
this.interact = new InteractionManager(this.session.actions);
this.music = new YTMusic(this.session);
this.studio = new YTStudio(this.session);
this.kids = new YTKids(this.session);
this.actions = this.session.actions;
}
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
return new Innertube(await Session.create(config));
}
/**
* Retrieves video info.
* @param target - The video id or `NavigationEndpoint`.
* @param client - The client to use.
*/
async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ target });
let payload: {
videoId: string,
playlistId?: string,
params?: string,
playlistIndex?: number
};
if (target instanceof NavigationEndpoint) {
const video_id = target.payload?.videoId;
if (!video_id) {
throw new InnertubeError('Missing video id in endpoint payload.', target);
}
payload = {
videoId: video_id
};
if (target.payload.playlistId) {
payload.playlistId = target.payload.playlistId;
}
if (target.payload.params) {
payload.params = target.payload.params;
}
if (target.payload.index) {
payload.playlistIndex = target.payload.index;
}
} else if (typeof target === 'string') {
payload = {
videoId: target
};
} else {
throw new InnertubeError('Invalid target, expected either a video id or a valid NavigationEndpoint', target);
}
const cpn = generateRandomString(16);
const initial_info = this.actions.getVideoInfo(payload.videoId, cpn, client);
const continuation = this.actions.execute('/next', payload);
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.actions, this.session.player, cpn);
}
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
const cpn = generateRandomString(16);
const response = await this.actions.getVideoInfo(video_id, cpn, client);
return new VideoInfo([ response ], this.actions, this.session.player, cpn);
}
/**
* Searches a given query.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const args = {
query,
...{
params: filters ? Proto.encodeSearchFilters(filters) : undefined
}
};
const response = await this.actions.execute('/search', args);
return new Search(this.actions, response);
}
/**
* Retrieves search suggestions for a given query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`);
url.searchParams.set('q', query);
url.searchParams.set('hl', this.session.context.client.hl);
url.searchParams.set('gl', this.session.context.client.gl);
url.searchParams.set('ds', 'yt');
url.searchParams.set('client', 'youtube');
url.searchParams.set('xssi', 't');
url.searchParams.set('oe', 'UTF');
const response = await this.session.http.fetch(url);
const response_data = await response.text();
const data = JSON.parse(response_data.replace(')]}\'', ''));
const suggestions = data[1].map((suggestion: any) => suggestion[0]);
return suggestions;
}
/**
* Retrieves comments for a video.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.execute('/next', { continuation: payload });
return new Comments(this.actions, response.data);
}
/**
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new HomeFeed(this.actions, response);
}
/**
* Retrieves YouTube's content guide.
*/
async getGuide(): Promise<Guide> {
const response = await this.actions.execute('/guide');
return new Guide(response.data);
}
/**
* Returns the account's library.
*/
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(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
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(): 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
*/
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response);
}
/**
* Retrieves notifications.
*/
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(): 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): Promise<Playlist> {
throwIfMissing({ id });
if (!id.startsWith('VL')) {
id = `VL${id}`;
}
const response = await this.actions.execute('/browse', { browseId: id });
return new Playlist(this.actions, response);
}
/**
* Retrieves a given hashtag's page.
* @param hashtag - The hashtag to fetch.
*/
async getHashtag(hashtag: string): Promise<HashtagFeed> {
throwIfMissing({ hashtag });
const params = Proto.encodeHashtag(hashtag);
const response = await this.actions.execute('/browse', { browseId: 'FEhashtag', params });
return new HashtagFeed(this.actions, response);
}
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
* @param video_id - The video id.
* @param options - Format options.
*/
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<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): Promise<ReadableStream<Uint8Array>> {
const info = await this.getBasicInfo(video_id, options?.client);
return info.download(options);
}
/**
* Resolves the given URL.
* @param url - The URL.
*/
async resolveURL(url: string): Promise<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;

110
src/core/AccountManager.ts Normal file
View File

@@ -0,0 +1,110 @@
import Proto from '../proto/index.js';
import type Actions from './Actions.js';
import type { ApiResponse } from './Actions.js';
import Analytics from '../parser/youtube/Analytics.js';
import TimeWatched from '../parser/youtube/TimeWatched.js';
import AccountInfo from '../parser/youtube/AccountInfo.js';
import Settings from '../parser/youtube/Settings.js';
import { InnertubeError } from '../utils/Utils.js';
class AccountManager {
#actions: Actions;
channel: {
editName: (new_name: string) => Promise<ApiResponse>;
editDescription: (new_description: string) => Promise<ApiResponse>;
getBasicAnalytics: () => Promise<Analytics>;
};
constructor(actions: Actions) {
this.#actions = actions;
this.channel = {
/**
* Edits channel name.
* @param new_name - The new channel name.
*/
editName: (new_name: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_name', {
givenName: new_name,
client: 'ANDROID'
});
},
/**
* Edits channel description.
* @param new_description - The new description.
*/
editDescription: (new_description: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
return this.#actions.execute('/channel/edit_description', {
givenDescription: new_description,
client: 'ANDROID'
});
},
/**
* Retrieves basic channel analytics.
*/
getBasicAnalytics: () => this.getAnalytics()
};
}
/**
* Retrieves channel info.
*/
async getInfo(): Promise<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);
}
/**
* Retrieves time watched statistics.
*/
async getTimeWatched(): Promise<TimeWatched> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPtime_watched',
client: 'ANDROID'
});
return new TimeWatched(response);
}
/**
* Opens YouTube settings.
*/
async getSettings(): Promise<Settings> {
const response = await this.#actions.execute('/browse', {
browseId: 'SPaccount_overview'
});
return new Settings(this.#actions, response);
}
/**
* Retrieves basic channel analytics.
*/
async getAnalytics(): Promise<Analytics> {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
return new Analytics(response);
}
}
export default AccountManager;

231
src/core/Actions.ts Normal file
View File

@@ -0,0 +1,231 @@
import Parser, { NavigateAction } from '../parser/index.js';
import { InnertubeError } from '../utils/Utils.js';
import type Session from './Session.js';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
INextResponse, IPlayerResponse, IResolveURLResponse,
ISearchResponse, IUpdatedMetadataResponse,
IParsedResponse, IRawResponse
} from '../parser/types/index.js';
export interface ApiResponse {
success: boolean;
status_code: number;
data: IRawResponse;
}
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type ParsedResponse<T> =
T extends '/player' ? IPlayerResponse :
T extends '/search' ? ISearchResponse :
T extends '/browse' ? IBrowseResponse :
T extends '/next' ? INextResponse :
T extends '/updated_metadata' ? IUpdatedMetadataResponse :
T extends '/navigation/resolve_url' ? IResolveURLResponse :
T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
IParsedResponse;
class Actions {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
get session(): Session {
return this.#session;
}
/**
* Mimmics the Axios API using Fetch's Response object.
* @param response - The response object.
*/
async #wrap(response: Response): Promise<ApiResponse> {
return {
success: response.ok,
status_code: response.status,
data: JSON.parse(await response.text())
};
}
/**
* Used to retrieve video info.
* @param id - The video ID.
* @param cpn - Content Playback Nonce.
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ApiResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
referer: 'https://www.youtube.com',
currentUrl: `/watch?v=${id}`,
autonavState: 'STATE_NONE',
signatureTimestamp: this.#session.player?.sts || 0,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
}
},
attestationRequest: {
omitBotguardData: true
},
videoId: id
};
if (client) {
data.client = client;
}
if (cpn) {
data.cpn = cpn;
}
if (playlist_id) {
data.playlistId = playlist_id;
}
const response = await this.#session.http.fetch('/player', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Makes calls to the playback tracking API.
* @param url - The URL to call.
* @param client - The client to use.
* @param params - Call parameters.
*/
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
const s_url = new URL(url);
s_url.searchParams.set('ver', '2');
s_url.searchParams.set('c', client.client_name.toLowerCase());
s_url.searchParams.set('cbrver', client.client_version);
s_url.searchParams.set('cver', client.client_version);
for (const key of Object.keys(params)) {
s_url.searchParams.set(key, params[key]);
}
const response = await this.#session.http.fetch(s_url);
return response;
}
/**
* Executes an API call.
* @param endpoint - The endpoint to call.
* @param args - Call arguments
*/
async execute<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 && !args.protobuf) {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
}
if (Reflect.has(data, 'override_endpoint'))
delete data.override_endpoint;
if (Reflect.has(data, 'parse'))
delete data.parse;
if (Reflect.has(data, 'request'))
delete data.request;
if (Reflect.has(data, 'clientActions'))
delete data.clientActions;
if (Reflect.has(data, 'settingItemIdForClient'))
delete data.settingItemIdForClient;
if (Reflect.has(data, 'action')) {
data.actions = [ data.action ];
delete data.action;
}
if (Reflect.has(data, 'boolValue')) {
data.newValue = { boolValue: data.boolValue };
delete data.boolValue;
}
if (Reflect.has(data, 'token')) {
data.continuation = data.token;
delete data.token;
}
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else if (args) {
data = args.serialized_data;
}
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
const response = await this.#session.http.fetch(target_endpoint, {
method: 'POST',
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
'Content-Type': args?.protobuf ?
'application/x-protobuf' :
'application/json'
}
});
if (args?.parse) {
let parsed_response = Parser.parseResponse<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'
].includes(id);
}
}
export default Actions;

215
src/core/Feed.ts Normal file
View File

@@ -0,0 +1,215 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.js';
import Parser, { ReloadContinuationItemsCommand } from '../parser/index.js';
import { concatMemos, InnertubeError } from '../utils/Utils.js';
import type Actions from './Actions.js';
import BackstagePost from '../parser/classes/BackstagePost.js';
import SharedPost from '../parser/classes/SharedPost.js';
import Channel from '../parser/classes/Channel.js';
import CompactVideo from '../parser/classes/CompactVideo.js';
import GridChannel from '../parser/classes/GridChannel.js';
import GridPlaylist from '../parser/classes/GridPlaylist.js';
import GridVideo from '../parser/classes/GridVideo.js';
import Playlist from '../parser/classes/Playlist.js';
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo.js';
import PlaylistVideo from '../parser/classes/PlaylistVideo.js';
import Post from '../parser/classes/Post.js';
import ReelItem from '../parser/classes/ReelItem.js';
import ReelShelf from '../parser/classes/ReelShelf.js';
import RichShelf from '../parser/classes/RichShelf.js';
import Shelf from '../parser/classes/Shelf.js';
import Tab from '../parser/classes/Tab.js';
import Video from '../parser/classes/Video.js';
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction.js';
import ContinuationItem from '../parser/classes/ContinuationItem.js';
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults.js';
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults.js';
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo.js';
import type MusicQueue from '../parser/classes/MusicQueue.js';
import type RichGrid from '../parser/classes/RichGrid.js';
import type SectionList from '../parser/classes/SectionList.js';
import type { IParsedResponse } from '../parser/types/index.js';
import type { ApiResponse } from './Actions.js';
class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
#continuation?: ObservedArray<ContinuationItem>;
#actions: Actions;
#memo: Memo;
constructor(actions: Actions, response: ApiResponse | IParsedResponse, already_parsed = false) {
if (this.#isParsed(response) || already_parsed) {
this.#page = response as T;
} else {
this.#page = Parser.parseResponse<T>(response.data);
}
const memo = concatMemos(...[
this.#page.contents_memo,
this.#page.continuation_contents_memo,
this.#page.on_response_received_commands_memo,
this.#page.on_response_received_endpoints_memo,
this.#page.on_response_received_actions_memo,
this.#page.sidebar_memo,
this.#page.header_memo
]);
if (!memo)
throw new InnertubeError('No memo found in feed');
this.#memo = memo;
this.#actions = actions;
}
#isParsed(response: IParsedResponse | ApiResponse): response is IParsedResponse {
return !('data' in response);
}
/**
* Get all videos on a given page via memo
*/
static getVideosFromMemo(memo: Memo) {
return memo.getType<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
Video,
GridVideo,
ReelItem,
CompactVideo,
PlaylistVideo,
PlaylistPanelVideo,
WatchCardCompactVideo
]);
}
/**
* Get all playlists on a given page via memo
*/
static getPlaylistsFromMemo(memo: Memo) {
return memo.getType<Playlist | GridPlaylist>([ Playlist, GridPlaylist ]);
}
/**
* Get all the videos in the feed
*/
get videos() {
return Feed.getVideosFromMemo(this.#memo);
}
/**
* Get all the community posts in the feed
*/
get posts() {
return this.#memo.getType<Post | BackstagePost | SharedPost>([ BackstagePost, Post, SharedPost ]);
}
/**
* Get all the channels in the feed
*/
get channels() {
return this.#memo.getType<Channel | GridChannel>([ Channel, GridChannel ]);
}
/**
* Get all playlists in the feed
*/
get playlists() {
return Feed.getPlaylistsFromMemo(this.#memo);
}
get memo() {
return this.#memo;
}
/**
* Returns contents from the page.
*/
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;
}
/**
* Returns all segments/sections from the page.
*/
get shelves() {
return this.#memo.getType<Shelf | RichShelf | ReelShelf>([ Shelf, RichShelf, ReelShelf ]);
}
/**
* Finds shelf by title.
*/
getShelf(title: string) {
return this.shelves.get({ title });
}
/**
* Returns secondary contents from the page.
*/
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
if (!this.#page.contents?.is_node)
return undefined;
const node = this.#page.contents?.item();
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
return undefined;
return node.secondary_contents;
}
get actions(): Actions {
return this.#actions;
}
/**
* Get the original page data
*/
get page(): T {
return this.#page;
}
/**
* Checks if the feed has continuation.
*/
get has_continuation(): boolean {
return (this.#memo.get('ContinuationItem') || []).length > 0;
}
/**
* Retrieves continuation data as it is.
*/
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<T>(this.#actions, { parse: true });
return response;
}
this.#continuation = this.#memo.getType(ContinuationItem);
if (this.#continuation)
return this.getContinuationData();
}
/**
* Retrieves next batch of contents and returns a new {@link Feed} object.
*/
async getContinuation(): Promise<Feed<T>> {
const continuation_data = await this.getContinuationData();
if (!continuation_data)
throw new InnertubeError('Could not get continuation data');
return new Feed<T>(this.actions, continuation_data, true);
}
}
export default Feed;

View File

@@ -0,0 +1,74 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip.js';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar.js';
import { InnertubeError } from '../utils/Utils.js';
import Feed from './Feed.js';
import type { ObservedArray } from '../parser/helpers.js';
import type { IParsedResponse } from '../parser/types/ParsedResponse.js';
import type Actions from './Actions.js';
import type { ApiResponse } from './Actions.js';
class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
super(actions, data, already_parsed);
}
/**
* Returns the filter chips.
*/
get filter_chips(): ObservedArray<ChipCloudChip> {
if (this.#chips)
return this.#chips || [];
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
if (this.memo.getType(FeedFilterChipBar)?.length === 0)
throw new InnertubeError('There are no feed filter chipbars');
this.#chips = this.memo.getType(ChipCloudChip);
return this.#chips || [];
}
/**
* 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): 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 });
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
} else if (filter.type === 'ChipCloudChip') {
target_filter = filter;
} else {
throw new InnertubeError('Invalid filter');
}
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, { parse: true });
if (!response)
throw new InnertubeError('Failed to get filtered feed');
return new Feed(this.actions, response, true);
}
}
export default FilterableFeed;

View File

@@ -0,0 +1,187 @@
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;
constructor(actions: Actions) {
this.#actions = actions;
}
/**
* Likes a given video.
* @param video_id - The video ID
*/
async like(video_id: string): Promise<ApiResponse> {
throwIfMissing({ 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): Promise<ApiResponse> {
throwIfMissing({ 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 removeRating(video_id: string): Promise<ApiResponse> {
throwIfMissing({ 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): Promise<ApiResponse> {
throwIfMissing({ channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/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): Promise<ApiResponse>{
throwIfMissing({ channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You must be signed in to perform this operation.');
const action = await this.#actions.execute('/subscription/unsubscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'CgIIAhgA'
});
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): Promise<ApiResponse> {
throwIfMissing({ 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;
}
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param target_language - an ISO language code
* @param args - optional arguments
*/
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
throwIfMissing({ text, target_language });
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;
return {
success: response.success,
status_code: response.status_code,
translated_content: mutation.translatedContent.content,
data: response.data
};
}
/**
* 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'): Promise<ApiResponse> {
throwIfMissing({ channel_id, type });
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;
}
}
export default InteractionManager;

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;

369
src/core/Music.ts Normal file
View File

@@ -0,0 +1,369 @@
import Album from '../parser/ytmusic/Album.js';
import Artist from '../parser/ytmusic/Artist.js';
import Explore from '../parser/ytmusic/Explore.js';
import HomeFeed from '../parser/ytmusic/HomeFeed.js';
import Library from '../parser/ytmusic/Library.js';
import Playlist from '../parser/ytmusic/Playlist.js';
import Recap from '../parser/ytmusic/Recap.js';
import Search from '../parser/ytmusic/Search.js';
import TrackInfo from '../parser/ytmusic/TrackInfo.js';
import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo.js';
import Message from '../parser/classes/Message.js';
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf.js';
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf.js';
import MusicQueue from '../parser/classes/MusicQueue.js';
import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem.js';
import PlaylistPanel from '../parser/classes/PlaylistPanel.js';
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection.js';
import SectionList from '../parser/classes/SectionList.js';
import Tab from '../parser/classes/Tab.js';
import { observe } from '../parser/helpers.js';
import Proto from '../proto/index.js';
import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils.js';
import type { ObservedArray, YTNode } from '../parser/helpers.js';
import type Actions from './Actions.js';
import type Session from './Session.js';
export type SearchFilters = {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
};
class Music {
#session: Session;
#actions: Actions;
constructor(session: Session) {
this.#session = session;
this.#actions = session.actions;
}
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
return this.#fetchInfoFromListItem(target);
} else if (typeof target === 'string') {
return this.#fetchInfoFromVideoId(target);
}
throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
}
async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
const cpn = generateRandomString(16);
const initial_info = this.#actions.execute('/player', {
cpn,
client: 'YTMUSIC',
videoId: video_id,
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = this.#actions.execute('/next', {
client: 'YTMUSIC',
videoId: video_id
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
if (!list_item)
throw new InnertubeError('List item cannot be undefined');
if (!list_item.endpoint)
throw new Error('This item does not have an endpoint.');
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
signatureTimestamp: this.#session.player?.sts || 0
}
}
});
const continuation = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
});
const response = await Promise.all([ initial_info, continuation ]);
return new TrackInfo(response, this.#actions, cpn);
}
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const payload: {
query: string;
client: string;
params?: string;
} = { query, client: 'YTMUSIC' };
if (filters.type && filters.type !== 'all') {
payload.params = Proto.encodeMusicSearchFilters(filters);
}
const response = await this.#actions.execute('/search', payload);
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.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
return new HomeFeed(response, this.#actions);
}
/**
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
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.
*/
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 });
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
return new Album(response);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
if (!playlist_id.startsWith('VL')) {
playlist_id = `VL${playlist_id}`;
}
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 });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.first();
if (!tab)
throw new InnertubeError('Could not find target tab.');
const music_queue = tab.content?.as(MusicQueue);
if (!music_queue || !music_queue.content)
throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);
const playlist_panel = music_queue.content.as(PlaylistPanel);
if (!playlist_panel.playlist_id && automix) {
const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel).first();
}
return playlist_panel;
}
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
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, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
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 });
const data = await this.#actions.execute('/next', {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
const tabs = data.contents_memo?.getType(Tab);
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, { client: 'YTMUSIC', parse: true });
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;
return section_list.firstOfType(MusicDescriptionShelf);
}
/**
* Retrieves recap.
*/
async getRecap(): Promise<Recap> {
const response = await this.#actions.execute('/browse', {
browseId: 'FEmusic_listening_review',
client: 'YTMUSIC_ANDROID'
});
return new Recap(response, this.#actions);
}
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
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];
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
return search_suggestions_section?.contents.array();
}
}
export default Music;

269
src/core/OAuth.ts Normal file
View File

@@ -0,0 +1,269 @@
import Constants from '../utils/Constants.js';
import { OAuthError, Platform } from '../utils/Utils.js';
import type Session from './Session.js';
export interface Credentials {
/**
* Token used to sign in.
*/
access_token: string;
/**
* Token used to get a new access token.
*/
refresh_token: string;
/**
* Access token's expiration date, which is usually 24hrs-ish.
*/
expires: Date;
}
// TODO: actual type info for this.
export type OAuthAuthPendingData = any;
export type OAuthAuthEventHandler = (data: {
credentials: Credentials;
status: 'SUCCESS';
}) => any;
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
class OAuth {
#identity?: Record<string, string>;
#session: Session;
#credentials?: Credentials;
#polling_interval = 5;
constructor(session: Session) {
this.#session = session;
}
/**
* Starts the auth flow in case no valid credentials are available.
*/
async init(credentials?: Credentials): Promise<void> {
this.#credentials = credentials;
if (this.validateCredentials()) {
if (!this.has_access_token_expired)
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
} else if (!(await this.#loadCachedCredentials())) {
await this.#getUserCode();
}
}
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(): Promise<boolean> {
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
if (!data) return false;
const decoder = new TextDecoder();
const credentials = JSON.parse(decoder.decode(data));
this.#credentials = {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires: new Date(credentials.expires)
};
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
return true;
}
async removeCache(): Promise<void> {
await this.#session.cache?.remove('youtubei_oauth_credentials');
}
/**
* Asks the server for a user code and verification URL.
*/
async #getUserCode(): Promise<void> {
this.#identity = await this.#getClientIdentity();
const data = {
client_id: this.#identity.client_id,
scope: Constants.OAUTH.SCOPE,
device_id: Platform.shim.uuidv4(),
model_name: Constants.OAUTH.MODEL_NAME
};
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
this.#session.emit('auth-pending', response_data);
this.#polling_interval = response_data.interval;
this.#startPolling(response_data.device_code);
}
/**
* Polls the authorization server until access is granted by the user.
*/
#startPolling(device_code: string): void {
const poller = setInterval(async () => {
const data = {
...this.#identity,
code: device_code,
grant_type: Constants.OAUTH.GRANT_TYPE
};
try {
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
if (response_data.error) {
switch (response_data.error) {
case 'access_denied':
this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' }));
break;
case 'expired_token':
this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' }));
clearInterval(poller);
this.#getUserCode();
break;
default:
break;
}
return;
}
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
this.#credentials = {
access_token: response_data.access_token,
refresh_token: response_data.refresh_token,
expires: expiration_date
};
this.#session.emit('auth', {
credentials: this.#credentials,
status: 'SUCCESS'
});
clearInterval(poller);
} catch (err) {
clearInterval(poller);
return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err }));
}
}, this.#polling_interval * 1000);
}
/**
* Refresh access token if the same has expired.
*/
async refreshIfRequired(): Promise<void> {
if (this.has_access_token_expired) {
await this.#refreshAccessToken();
}
}
async #refreshAccessToken(): Promise<void> {
if (!this.#credentials) return;
this.#identity = await this.#getClientIdentity();
const data = {
...this.#identity,
refresh_token: this.#credentials.refresh_token,
grant_type: 'refresh_token'
};
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
body: JSON.stringify(data),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
this.#credentials = {
access_token: response_data.access_token,
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
expires: expiration_date
};
this.#session.emit('update-credentials', {
credentials: this.#credentials,
status: 'SUCCESS'
});
}
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), {
method: 'post'
});
}
/**
* Retrieves client identity from YouTube TV.
*/
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();
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1];
if (!url_body)
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE });
const client_identity = (await script.text())
.replace(/\n/g, '')
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
const groups = client_identity?.groups;
if (!groups)
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
return groups;
}
get credentials(): Credentials | undefined {
return this.#credentials;
}
get has_access_token_expired(): boolean {
const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity;
return new Date().getTime() > timestamp;
}
validateCredentials(): this is this & { credentials: Credentials } {
return this.#credentials &&
Reflect.has(this.#credentials, 'access_token') &&
Reflect.has(this.#credentials, 'refresh_token') &&
Reflect.has(this.#credentials, 'expires') || false;
}
}
export default OAuth;

206
src/core/Player.ts Normal file
View File

@@ -0,0 +1,206 @@
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.js';
import Constants from '../utils/Constants.js';
import { ICache } from '../types/Cache.js';
import { FetchFunction } from '../types/PlatformShim.js';
export default class Player {
#nsig_sc;
#sig_sc;
#sig_sc_timestamp;
#player_id;
constructor(signature_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
this.#nsig_sc = nsig_sc;
this.#sig_sc = sig_sc;
this.#sig_sc_timestamp = signature_timestamp;
this.#player_id = player_id;
}
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);
if (res.status !== 200)
throw new PlayerError('Failed to request player id');
const js = await res.text();
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
if (!player_id)
throw new PlayerError('Failed to get player id');
// We have the playerID now we can check if we have a cached player
if (cache) {
const cached_player = await Player.fromCache(cache, player_id);
if (cached_player)
return cached_player;
}
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
const player_res = await fetch(player_url, {
headers: {
'user-agent': getRandomUserAgent('desktop')
}
});
if (!player_res.ok) {
throw new PlayerError(`Failed to get player data: ${player_res.status}`);
}
const player_js = await player_res.text();
const sig_timestamp = this.extractSigTimestamp(player_js);
const sig_sc = this.extractSigSourceCode(player_js);
const nsig_sc = this.extractNSigSourceCode(player_js);
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
}
decipher(url?: string, signature_cipher?: string, cipher?: string): string {
url = url || signature_cipher || cipher;
if (!url)
throw new PlayerError('No valid URL to decipher');
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
if (signature_cipher || cipher) {
const signature = Platform.shim.eval(this.#sig_sc, {
sig: args.get('s')
});
if (typeof signature !== 'string')
throw new PlayerError('Failed to decipher signature');
const sp = args.get('sp');
sp ?
url_components.searchParams.set(sp, signature) :
url_components.searchParams.set('signature', signature);
}
const n = url_components.searchParams.get('n');
if (n) {
const nsig = Platform.shim.eval(this.#nsig_sc, {
nsig: n
});
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!');
}
url_components.searchParams.set('n', nsig);
}
return url_components.toString();
}
static async fromCache(cache: ICache, player_id: string): Promise<Player | null> {
const buffer = await cache.get(player_id);
if (!buffer)
return null;
const view = new DataView(buffer);
const version = view.getUint32(0, true);
if (version !== Player.LIBRARY_VERSION)
return null;
const sig_timestamp = view.getUint32(4, true);
const sig_len = view.getUint32(8, true);
const sig_buf = buffer.slice(12, 12 + sig_len);
const nsig_buf = buffer.slice(12 + sig_len);
const decoder = new TextDecoder();
const sig_sc = decoder.decode(sig_buf);
const nsig_sc = decoder.decode(nsig_buf);
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
}
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?: ICache): Promise<void> {
if (!cache) return;
const encoder = new TextEncoder();
const sig_buf = encoder.encode(this.#sig_sc);
const nsig_buf = encoder.encode(this.#nsig_sc);
const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
const view = new DataView(buffer);
view.setUint32(0, Player.LIBRARY_VERSION, true);
view.setUint32(4, this.#sig_sc_timestamp, true);
view.setUint32(8, sig_buf.byteLength, true);
new Uint8Array(buffer).set(sig_buf, 12);
new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);
await cache.set(this.#player_id, new Uint8Array(buffer));
}
static extractSigTimestamp(data: string): number {
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
}
static extractSigSourceCode(data: string): string {
const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
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);`;
}
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)
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
return sc;
}
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(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc(): string {
return this.#sig_sc;
}
static get LIBRARY_VERSION(): number {
return 2;
}
}

209
src/core/PlaylistManager.ts Normal file
View File

@@ -0,0 +1,209 @@
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.js';
class PlaylistManager {
#actions: Actions;
constructor(actions: Actions) {
this.#actions = actions;
}
/**
* 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[]): Promise<{ success: boolean; status_code: number; playlist_id?: string; data: any }> {
throwIfMissing({ title, 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('/playlist/create', {
title,
ids: video_ids,
parse: false
});
return {
success: response.success,
status_code: response.status_code,
playlist_id: response.data.playlistId,
data: response.data
};
}
/**
* Deletes a given playlist.
* @param playlist_id - The playlist ID.
*/
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 {
playlist_id,
success: response.success,
status_code: response.status_code,
data: response.data
};
}
/**
* 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[]): 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) => ({
action: 'ACTION_ADD_VIDEO',
addedVideoId: id
})),
parse: false
});
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
/**
* 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[]): 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 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)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string;
setVideoId: string;
}[]
};
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
videos.forEach((video) =>
payload.actions.push({
action: 'ACTION_REMOVE_VIDEO',
setVideoId: video.key('set_video_id').string()
})
);
if (payload.actions.length < video_ids.length) {
const next = await pl.getContinuation();
return getSetVideoIds(next);
}
};
await getSetVideoIds(playlist);
if (!payload.actions.length)
throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
/**
* 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): Promise<{ playlist_id: string; action_result: any; }> {
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
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)
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
const payload = {
playlistId: playlist_id,
actions: [] as {
action: string,
setVideoId?: string,
movedSetVideoIdPredecessor?: string
}[]
};
let set_video_id_0: string | undefined, set_video_id_1: string | undefined;
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());
const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());
set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();
set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();
if (!set_video_id_0 || !set_video_id_1) {
const next = await pl.getContinuation();
return getSetVideoIds(next);
}
};
await getSetVideoIds(playlist);
payload.actions.push({
action: 'ACTION_MOVE_VIDEO_AFTER',
setVideoId: set_video_id_0,
movedSetVideoIdPredecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
return {
playlist_id,
action_result: response.data.actions // TODO: implement actions in the parser
};
}
}
export default PlaylistManager;

416
src/core/Session.ts Normal file
View File

@@ -0,0 +1,416 @@
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 from '../utils/HTTPClient.js';
import { Platform, DeviceCategory, 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_CREATOR = 'ANDROID_CREATOR',
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
client: {
hl: string;
gl: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
userAgent: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
osName: string;
osVersion: string;
platform: string;
clientFormFactor: string;
userInterfaceTheme: string;
timeZone: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
};
contentSettings: {
corpusPreference: string;
kidsNoSearchMode: string;
};
};
};
user: {
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
};
thirdParty?: {
embedUrl: string;
};
request: {
useSsl: true;
};
}
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;
/**
* 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: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
oauth: OAuth;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: ICache;
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;
this.http = new HTTPClient(this, cookie, fetch);
this.actions = new Actions(this);
this.oauth = new OAuth(this);
this.logged_in = !!cookie;
this.cache = cache;
}
on(type: 'auth', listener: OAuthAuthEventHandler): void;
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
on(type: 'update-credentials', listener: OAuthAuthEventHandler): void;
on(type: string, listener: (...args: any[]) => void): void {
super.on(type, listener);
}
once(type: 'auth', listener: OAuthAuthEventHandler): void;
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
once(type: string, listener: (...args: any[]) => void): void {
super.once(type, listener);
}
static async create(options: SessionOptions = {}) {
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 = '',
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 = 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': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${Constants.CLIENTS.WEB.STATIC_VISITOR_ID};`
}
});
if (!res.ok)
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
const text = await res.text();
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
const ytcfg = data[0][2];
const api_version = `v${ytcfg[0][0][6]}`;
const [ [ device_info ], api_key ] = ytcfg;
const context: Context = {
client: {
hl: device_info[0],
gl: options.location || device_info[2],
remoteHost: device_info[3],
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: device_info[13],
userAgent: device_info[14],
clientName: options.client_name,
clientVersion: device_info[16],
osName: device_info[17],
osVersion: device_info[18],
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: device_info[79] || options.time_zone,
browserName: device_info[86],
browserVersion: device_info[87],
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: {
useSsl: true
}
};
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 = Constants.CLIENTS.WEB.STATIC_VISITOR_ID;
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);
this.once('auth', (data) => {
this.off('auth-error', error_handler);
if (data.status === 'SUCCESS') {
this.logged_in = true;
resolve();
}
reject(data);
});
this.once('auth-error', error_handler);
try {
await this.oauth.init(credentials);
if (this.oauth.validateCredentials()) {
await this.oauth.refreshIfRequired();
this.logged_in = true;
resolve();
}
} catch (err) {
reject(err);
}
});
}
/**
* Signs out of the current account and revokes the credentials.
*/
async signOut(): Promise<Response | undefined> {
if (!this.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const response = await this.oauth.revokeCredentials();
this.logged_in = false;
return response;
}
/**
* InnerTube API key.
*/
get key(): string {
return this.#key;
}
/**
* InnerTube API version.
*/
get api_version(): string {
return this.#api_version;
}
get client_version(): string {
return this.#context.client.clientVersion;
}
get client_name(): string {
return this.#context.client.clientName;
}
get account_index(): number {
return this.#account_index;
}
get context(): Context {
return this.#context;
}
get player(): Player | undefined {
return this.#player;
}
get lang(): string {
return this.#context.client.hl;
}
}

211
src/core/Studio.ts Normal file
View File

@@ -0,0 +1,211 @@
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;
scottyResourceId: string;
}
interface InitialUploadData {
frontend_upload_id: string;
upload_id: string;
upload_url: string;
scotty_resource_id: string;
chunk_granularity: string;
}
export interface VideoMetadata {
title?: string;
description?: string;
tags?: string[];
category?: number;
license?: string;
age_restricted?: boolean;
made_for_kids?: boolean;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
}
export interface UploadedVideoMetadata {
title?: string;
description?: string;
privacy?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
is_draft?: boolean;
}
class Studio {
#session: Session;
constructor(session: Session) {
this.#session = session;
}
/**
* Uploads a custom thumbnail and sets it for a video.
* @example
* ```ts
* const buffer = fs.readFileSync('./my_awesome_thumbnail.jpg');
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
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.');
const payload = Proto.encodeCustomThumbnailPayload(video_id, buffer);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Updates given video's metadata.
* @example
* ```ts
* const response = await yt.studio.updateVideoMetadata('videoid', {
* tags: [ 'astronomy', 'NASA', 'APOD' ],
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* license: 'creative_commons'
* // ...
* });
* ```
*/
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', {
protobuf: true,
serialized_data: payload
});
return response;
}
/**
* Uploads a video to YouTube.
* @example
* ```ts
* const file = fs.readFileSync('./my_awesome_video.mp4');
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
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);
if (upload_result.status !== 'STATUS_SUCCESS')
throw new InnertubeError('Could not process video.');
const response = await this.#setVideoMetadata(initial_data, upload_result, metadata);
return response;
}
async #getInitialUploadData(): Promise<InitialUploadData> {
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/${Platform.shim.uuidv4()}`,
mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
transcodeResult: 'DISABLED',
connectionType: 'WIFI'
};
const response = await this.#session.http.fetch('/upload/youtubei', {
baseURL: Constants.URLS.YT_UPLOAD,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-goog-upload-command': 'start',
'x-goog-upload-protocol': 'resumable'
},
body: JSON.stringify(payload)
});
if (!response.ok)
throw new InnertubeError('Could not get initial upload data');
return {
frontend_upload_id,
upload_id: response.headers.get('x-guploader-uploadid') as string,
upload_url: response.headers.get('x-goog-upload-url') as string,
scotty_resource_id: response.headers.get('x-goog-upload-header-scotty-resource-id') as string,
chunk_granularity: response.headers.get('x-goog-upload-chunk-granularity') as string
};
}
async #uploadVideo(upload_url: string, file: BodyInit): Promise<UploadResult> {
const response = await this.#session.http.fetch_function(upload_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-goog-upload-command': 'upload, finalize',
'x-goog-upload-file-name': `file-${Date.now()}`,
'x-goog-upload-offset': '0'
},
body: file
});
if (!response.ok)
throw new InnertubeError('Could not upload video');
const data = await response.json();
return data;
}
async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadata) {
const metadata_payload = {
resourceId: {
scottyResourceId: {
id: upload_result.scottyResourceId
}
},
frontendUploadId: initial_data.frontend_upload_id,
initialMetadata: {
title: {
newTitle: metadata.title || new Date().toDateString()
},
description: {
newDescription: metadata.description || '',
shouldSegment: true
},
privacy: {
newPrivacy: metadata.privacy || 'PRIVATE'
},
draftState: {
isDraft: metadata.is_draft || false
}
}
};
const response = await this.#session.actions.execute('/upload/createvideo', {
client: 'ANDROID',
...metadata_payload
});
return response;
}
}
export default Studio;

61
src/core/TabbedFeed.ts Normal file
View File

@@ -0,0 +1,61 @@
import Tab from '../parser/classes/Tab.js';
import Feed from './Feed.js';
import { InnertubeError } from '../utils/Utils.js';
import type Actions from './Actions.js';
import type { ObservedArray } from '../parser/helpers.js';
import type { IParsedResponse } from '../parser/types/ParsedResponse.js';
import type { ApiResponse } from './Actions.js';
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);
}
get tabs(): string[] {
return this.#tabs?.map((tab) => tab.title.toString()) ?? [];
}
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`);
if (tab.selected)
return this;
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed<T>(this.#actions, response, false);
}
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();
}
}
export default TabbedFeed;

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';

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