Compare commits

...

252 Commits

Author SHA1 Message Date
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
LuanRT
398cd8728d 1.3.6 2022-03-07 19:30:14 -03:00
LuanRT
459c30528e fix: decipher n param only if necessary 2022-03-07 19:29:39 -03:00
LuanRT
6e1e96610c docs: fix table of contents 2022-03-07 19:25:09 -03:00
LuanRT
6d30aa3228 docs: oops 2022-03-03 03:37:47 -03:00
LuanRT
d33cb0b576 docs: add unsubscribe() snippet 2022-03-03 03:34:02 -03:00
LuanRT
51af4c3ffe chore: add issue & pull request template 2022-03-03 03:29:08 -03:00
LuanRT
b577a79893 chore: update lock file 2022-03-03 02:40:29 -03:00
LuanRT
da0c5e5887 chore(release): v1.3.5 2022-03-03 02:31:22 -03:00
LuanRT
b47350894d 2.0.0-0 2022-03-03 02:23:22 -03:00
LuanRT
c0387017e3 docs: add more examples 2022-03-03 02:22:48 -03:00
LuanRT
b286bc43df chore: update tests 2022-03-03 02:21:58 -03:00
LuanRT
61028a2ab9 style: format and refactor code 2022-03-03 02:21:32 -03:00
LuanRT
254588da81 feat: add acc settings and alternative to download 2022-03-03 02:18:03 -03:00
LuanRT
ef3e54775c feat: add watch history and playlist support 2022-03-03 02:13:00 -03:00
dependabot[bot]
30cec36660 Merge pull request #12 from LuanRT/dependabot/npm_and_yarn/follow-redirects-1.14.8 2022-02-14 16:26:42 +00:00
dependabot[bot]
427a1bd396 build(deps): bump follow-redirects from 1.14.7 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 16:24:09 +00:00
LuanRT
cf4901fd3c feat: automatically delete old players 2022-02-13 19:09:40 -03:00
LuanRT
2fd98a021f format: remove white space 2022-02-13 19:08:14 -03:00
LuanRT
cd64e30b69 chore: simplify format selection 2022-02-13 19:06:56 -03:00
LuanRT
2b5027eb06 fix: getLyrics() only working when signed-in 2022-02-06 15:26:16 -03:00
LuanRT
0c9f7135bf docs: oops 2022-02-05 19:25:23 -03:00
LuanRT
ce8a109398 docs: update table of contents 2022-02-05 19:23:07 -03:00
LuanRT
6aaa3360e8 docs: update YouTube Music examples 2022-02-05 19:17:12 -03:00
LuanRT
89c018c431 refactor: move getLyrics to Innertube.js 2022-02-05 19:16:36 -03:00
LuanRT
339a01f3a9 chore(release): v1.3.0 2022-02-05 18:45:30 -03:00
LuanRT
dd3f4c0009 chore: format code & other minor changes 2022-02-05 18:32:25 -03:00
LuanRT
7cd41e1d8a docs: add YouTube Music examples 2022-02-05 18:31:28 -03:00
LuanRT
6ac8561af2 feat: add lyrics support 2022-02-05 18:30:21 -03:00
LuanRT
b4607d531f fix(OAuth): secret not found due to bad regex 2022-02-04 15:20:35 -03:00
LuanRT
b3a1cdc1cd chore: remove ntoken 'translate' func var names 2022-02-03 04:54:37 -03:00
LuanRT
fd662df93d style: remove extra white space 2022-02-02 06:12:38 -03:00
LuanRT
8a1f4b4e55 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-02-02 06:04:49 -03:00
LuanRT
4ff83bdc3f style: add missing semi & rename some variables 2022-02-02 06:04:39 -03:00
LuanRT
c81e8e29ac chore: remove unnecessary condition 2022-02-01 18:49:17 -03:00
LuanRT
d5f884ff9b refactor: move refineNTokenData function to Utils 2022-02-01 16:47:02 -03:00
LuanRT
5517c2f202 fix: ntoken function 'translate2' not being parsed 2022-02-01 16:12:47 -03:00
LuanRT
3493a82765 chore: remove more unused variables 2022-01-31 04:31:45 -03:00
LuanRT
07f02d0dc1 chore: remove unnecessary variable 2022-01-30 20:47:26 -03:00
LuanRT
b2afa86744 chore: update deps 2022-01-30 20:43:29 -03:00
LuanRT
a1caa60750 1.2.9 2022-01-30 20:34:18 -03:00
LuanRT
e1dd718832 chore: format code and other minor changes 2022-01-30 20:28:00 -03:00
LuanRT
222bf1e61f fix: ntoken 'translate2' function not being parsed 2022-01-30 20:26:02 -03:00
LuanRT
3b48de20dd fix: oauth identity creds regex no longer working 2022-01-30 20:20:52 -03:00
LuanRT
348d901935 chore: update tests 2022-01-30 20:19:11 -03:00
LuanRT
94b12002ff feat: implement continuation requests for YTMusic 2022-01-18 15:55:07 -03:00
LuanRT
2720e8f251 chore(livechat): remove console.log 2022-01-18 15:52:35 -03:00
LuanRT
a8a1ec2182 fix (tests): video used for tests is no longer available 2022-01-18 15:50:43 -03:00
LuanRT
ee0d1bef40 deps: remove time-to-seconds dependency 2022-01-18 15:46:54 -03:00
LuanRT
5cad39ee44 fix: polling interval missing 2022-01-07 18:59:17 -03:00
LuanRT
e8ca248919 feat: add home feed support 2022-01-07 18:50:00 -03:00
LuanRT
44d09026b5 chore: simplify video details parser 2022-01-07 18:46:29 -03:00
LuanRT
ff044f4216 fix: error polling livechat due to dislikes 2022-01-07 18:45:30 -03:00
LuanRT
8153e6178c fix: subsfeed sections placeholders missing 2022-01-05 16:36:48 -03:00
LuanRT
ee3f1b4638 chore: update examples 2022-01-05 16:31:52 -03:00
LuanRT
86c8a7e0d2 fix: filter out undefined search results 2021-12-31 04:05:38 -03:00
LuanRT
b375ae2f06 chore: fix typo 2021-12-31 03:35:05 -03:00
LuanRT
2ff4b2ea95 test: remove node 12 build 2021-12-31 03:27:54 -03:00
LuanRT
599ab69107 refactor: rewrite inefficient code and add docs 2021-12-31 03:19:58 -03:00
LuanRT
c6c6dc24bd feat: add support for music search 2021-12-31 03:15:59 -03:00
LuanRT
fa2e0724c6 docs: fix a typo 2021-12-22 15:27:26 -03:00
LuanRT
6af689ada6 docs: improve documentation & add unseen notifications example 2021-12-22 15:22:10 -03:00
LuanRT
9997c0d939 build (package): release v1.2.8 2021-12-18 19:14:15 -03:00
LuanRT
3dee7fc12f fix: forgot to export getVideoInfo :v 2021-12-18 12:20:17 -03:00
LuanRT
4dff129b74 chore: update tests 2021-12-18 12:17:00 -03:00
LuanRT
7e86bb15e0 refactor (OAuth): a simpler & more efficient auth system 2021-12-18 12:03:44 -03:00
LuanRT
d0de164722 chore: update examples & format code 2021-12-18 00:24:57 -03:00
LuanRT
5d165ebb61 refactor: move all internal actions to Actions.js for better maintainability 2021-12-18 00:16:47 -03:00
LuanRT
2ad19adbe4 refactor: move search request code to Actions.js for better maintainability & organization 2021-12-17 23:55:39 -03:00
LuanRT
cabbdc9f50 chore: encode search filters correctly 2021-12-17 23:08:35 -03:00
LuanRT
fe84f31432 chore: add search filter protobuf message 2021-12-17 21:12:14 -03:00
LuanRT
22c605f528 perf (OAuth): check access token validity in a more efficient way 2021-12-13 21:58:02 -03:00
LuanRT
6777b59116 feat: include available stream quality in the metadata 2021-12-13 21:38:31 -03:00
stranothus
de70d851d8 Desktop version compatible
The desktop version is sent a different resopnse by the Innertube API
and streamingData needs to be accessed from data, rather than the third
index of data and through playerResponse.
2021-12-13 15:40:44 -06:00
stranothus
e20e671d16 Include available video qualities to metadata
The playerResponse streamingData adaptiveFormats are filter to include only those which
include a qualityLabel. This array is then mapped to an array of qualityLabels and sorted
from lowest to highest quality.
2021-12-13 09:29:45 -06:00
LuanRT
d0e1140029 chore: yes, more code formatting 2021-12-09 23:24:50 -03:00
LuanRT
bf483256fe chore: remove useless comments & format code 2021-12-09 22:45:18 -03:00
LuanRT
d4c32d47e1 build (package): release v1.2.7 2021-11-24 12:14:46 -03:00
LuanRT
70feab80da fix: check if dislike count is available to avoid unexpected errors 2021-11-23 07:17:17 -03:00
LuanRT
c006f49dc1 chore: remove unnecessary param 2021-11-23 06:09:12 -03:00
LuanRT
aeff0c3fdc build (package): increment version 2021-11-19 13:50:50 -03:00
LuanRT
00d67ed417 chore (OAuth): better & simpler regular expression 2021-11-19 13:29:02 -03:00
LuanRT
78f93c7118 fix: add “g” flag so it matches all possible strings 2021-11-19 13:27:07 -03:00
LuanRT
6db3f0ad91 fix: download not possible due to visitorData being undefined 2021-11-14 12:22:56 -03:00
UnbreakCode
cf48385f72 fixed x-goog-visitor-id for downloader 2021-11-14 15:46:21 +01:00
LuanRT
e70eab2416 build (package): increment version 2021-11-13 01:18:17 -03:00
LuanRT
771c6050c4 fix (n-token): yet again YouTube added new functions that do exactly the same thing as before but in a different way 2021-11-13 01:04:03 -03:00
LuanRT
5670228a4f docs: remove licence scan badge, it's not really necessary for this project 2021-11-05 09:12:47 -03:00
LuanRT
62ae384f27 docs: add license scan report and status 2021-11-05 08:42:15 -03:00
fossabot
185cdbd6ce Add license scan report and status
Signed off by: fossabot <badges@fossa.com>
2021-11-05 04:27:51 -07:00
LuanRT
5dd6ef9e24 fix: don't limit range end 2021-11-04 08:01:03 -03:00
LuanRT
309942090d docs: add info event response example 2021-11-04 04:27:35 -03:00
LuanRT
af4a4b8b82 . 2021-11-04 04:13:50 -03:00
LuanRT
b9c9d40077 feat: add support for custom data range 2021-11-04 04:05:16 -03:00
LuanRT
62fbc166c5 chore: format code 2021-11-03 23:34:58 -03:00
LuanRT
c3991dda32 fix (OAuth): forgot to change some variables to uppercase 2021-11-03 23:33:34 -03:00
LuanRT
95e804e8ea docs: fix typos 2021-11-03 21:41:55 -03:00
LuanRT
67a8435421 tests: throw an error if one or more tests fail 2021-11-02 16:41:45 -03:00
LuanRT
1847558d50 test: rename some vars & fix typos 2021-11-02 08:45:18 -03:00
LuanRT
bde915bce3 chore: remove comment on workflow file 2021-11-02 08:03:04 -03:00
LuanRT
a9ad3a31b5 chore: update workflow 2021-11-02 08:01:31 -03:00
LuanRT
e52e6138bd chore: format & fix typos 2021-11-02 07:57:33 -03:00
LuanRT
76248ad143 build (package): update test script 2021-11-02 07:51:36 -03:00
LuanRT
94f441a4e2 chore: format code 2021-11-02 07:45:16 -03:00
LuanRT
685e14fcc1 perf: move to object literal and simplify transformation functions 2021-11-02 07:40:31 -03:00
LuanRT
3cd115461f refactor: change constants to uppercase and refactor some code 2021-11-02 07:37:28 -03:00
LuanRT
6da4ee8fd4 chore: add tests 2021-11-02 07:34:47 -03:00
LuanRT
b095044baa build (package): increment version 2021-10-29 22:22:53 -03:00
LuanRT
ba2b757fdb fix (OAuth): remove any new lines so the client identity can be found more easily 2021-10-29 22:10:43 -03:00
104 changed files with 17665 additions and 2195 deletions

7
.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.github
test/
cache/
lib/proto/messages.js
coverage/
node_modules/

45
.eslintrc.yml Normal file
View File

@@ -0,0 +1,45 @@
env:
commonjs: true
es2021: true
node: true
extends: eslint:recommended
parserOptions:
ecmaVersion: latest
rules:
max-len:
- error
-
code: 200
ignoreComments: true
ignoreTrailingComments: true
ignoreStrings: true
ignoreTemplateLiterals: true
ignoreRegExpLiterals: true
quotes: [error, single]
no-template-curly-in-string: error
no-unreachable-loop: error
no-unused-private-class-members: error
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: error
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

2
.github/FUNDING.yml vendored
View File

@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
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']
custom: [ 'https://www.buymeacoffee.com/luanrt' ]

19
.github/ISSUE_TEMPLATE/FEATURE.md vendored Normal file
View File

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

38
.github/ISSUE_TEMPLATE/ISSUE.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
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 have provided sufficient information

15
.github/ISSUE_TEMPLATE/QUESTION.md vendored Normal file
View File

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

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

27
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# Pull Request Template
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have checked my code and corrected any misspellings

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

@@ -0,0 +1,17 @@
name: Lint
on: [push, pull_request]
jobs:
eslint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: npm install and lint
run: |
npm install
npm run lint

View File

@@ -1,7 +1,3 @@
# 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
# TODO: Make actual tests instead of just running the examples file
name: Build
on:
@@ -17,7 +13,7 @@ jobs:
strategy:
matrix:
node-version: [ 12.x, 14.x, 15.x]
node-version: [ 14.x, 15.x, 16.x ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@@ -27,4 +23,4 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: node ./examples
- run: npm run test

View File

@@ -15,5 +15,5 @@ jobs:
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: 30
days-before-close: 4

52
.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# YouTube player cache directory
cache/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.npmignore
# 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.*

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.

1652
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,92 @@
'use strict';
const fs = require('fs');
const Innertube = require('..');
async function start() {
const youtube = await new Innertube();
// Searching, getting details about videos & making interactions:
const search = await youtube.search('Looking for life on Mars - documentary');
console.info('Search results:', search);
if (search.videos.length === 0)
return console.error('Could not find any video about that on YouTube.');
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
console.info('Video details:', video);
if (video instanceof Error)
return console.error('Could not get details for ' + search.videos[0].title);
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));
}
'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);
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();

7
jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
'use strict';
module.exports = {
roots: [ '<rootDir>/test' ],
testMatch: [ '**/*.test.js' ],
testTimeout: 10000
};

View File

@@ -1,165 +0,0 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged 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]
};
break;
case 'comment/create_comment':
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.generateCommentParams(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_request_opts({ 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
};
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not logged in');
let data;
switch (action_type) {
case 'subscriptions_feed':
data = {
context: session.context,
browseId: 'FEsubscriptions'
};
break;
default:
}
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ 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
};
}
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged 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_request_opts({ 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
};
}
async function livechat(session, action_type, args = {}) {
let data;
switch (action_type) {
case 'live_chat/send_message':
data = {
context: session.context,
params: Utils.generateMessageParams(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;
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_request_opts({ session, params: args.params })).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
};
}
async function getContinuation(session, info = {}) {
let data = { context: session.context };
info.continuation_token && (data.continuation = info.continuation_token);
if (info.video_id) {
data.videoId = info.video_id;
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
};
data.captionsRequested = false;
}
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ 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, notifications, livechat, getContinuation };

View File

@@ -1,329 +0,0 @@
'use strict';
const Utils = require('./Utils');
const urls = {
YT_BASE_URL: 'https://www.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
};
const 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::'
};
const oauth_reqopts = {
headers: {
'accept': '*/*',
'origin': urls.YT_BASE_URL,
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
'content-type': 'application/json',
'referer': `${urls.YT_BASE_URL}/tv`,
'accept-language': 'en-US'
}
};
const 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
}
};
};
const innertube_request_opts = (info) => {
info.desktop === undefined && (info.desktop = true);
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': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
'origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
}
};
info.id && (req_opts.headers.referer = (info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL) + '/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;
};
const video_details_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': urls.YT_BASE_URL,
'lactMilliseconds': '-1'
}
},
context: context,
videoId: id
};
};
const stream_headers = (range) => {
let headers = {
'Accept': '*/*',
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
'Connection': 'keep-alive',
'Origin': urls.YT_BASE_URL,
'Referer': urls.YT_BASE_URL,
'DNT': '?1'
};
range && (headers.Range = range);
return headers;
};
const formatVideoData = (data, context, desktop) => {
let video_details = {};
let metadata = {};
if (desktop) {
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
metadata.view_count = parseInt(data.videoDetails.viewCount);
metadata.average_rating = data.videoDetails.averageRating;
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data.videoDetails.channelId;
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data.videoDetails.isLiveContent;
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data.videoDetails.isPrivate;
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
metadata.keywords = data.videoDetails.keywords || [];
video_details.title = data.videoDetails.title;
video_details.description = data.videoDetails.shortDescription;
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
video_details.metadata = metadata;
} else {
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
metadata.likes = parseInt(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, ''));
metadata.dislikes = parseInt(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, ''));
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
video_details.title = data[2].playerResponse.videoDetails.title;
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
// Functions
video_details.like = () => {};
video_details.dislike = () => {};
video_details.removeLike = () => {};
video_details.subscribe = () => {};
video_details.unsubscribe = () => {};
video_details.comment = () => {};
video_details.getComments = () => {};
video_details.setNotificationPref = () => {};
video_details.getLivechat = () => {};
// Additional metadata
video_details.metadata = metadata;
}
return video_details;
};
const base64_alphabet = {
normal: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
reverse: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
};
const filters = (order) => {
// It seems like all of these are just proto buffers, so I think it'll be easy to refactor
switch (order) {
case 'any,any,relevance':
return 'EgIQAQ%3D%3D';
case 'hour,any,relevance':
return 'EgIIAQ%3D%3D';
case 'day,any,relevance':
return 'EgQIAhAB';
case 'week,any,relevance':
return 'EgQIAxAB';
case 'month,any,relevance':
return 'EgQIBBAB';
case 'year,any,relevance':
return 'EgQIBRAB';
case 'any,short,relevance':
return 'EgQQARgB';
case 'hour,short,relevance':
return 'EgYIARABGAE%3D';
case 'day,short,relevance':
return 'EgYIAhABGAE%3D';
case 'week,short,relevance':
return 'EgYIAxABGAE%3D';
case 'month,short,relevance':
return 'EgYIBBABGAE%3D';
case 'year,short,relevance':
return 'EgYIBRABGAE%3D';
case 'any,long,relevance':
return 'EgQQARgC';
case 'hour,long,relevance':
return 'EgYIARABGAI%3D';
case 'day,long,relevance':
return 'EgYIAhABGAI%3D';
case 'week,long,relevance':
return 'EgYIAxABGAI%3D';
case 'month,long,relevance':
return 'EgYIBBABGAI%3D';
case 'year,long,relevance':
return 'EgYIBRABGAI%3D';
case 'any,any,age':
return 'CAISAhAB';
case 'hour,any,age':
return 'CAISBAgBEAE%3D';
case 'day,any,age':
return 'CAISBAgCEAE%3D';
case 'week,any,age':
return 'CAISBAgDEAE%3D';
case 'month,any,age':
return 'CAISBAgEEAE%3D';
case 'year,any,age':
return 'CAISBAgFEAE%3D';
case 'any,short,age':
return 'CAISBBABGAE%3D';
case 'hour,short,age':
return 'CAISBggBEAEYAQ%3D%3D';
case 'day,short,age':
return 'CAISBggCEAEYAQ%3D%3D';
case 'week,short,age':
return 'CAISBggDEAEYAQ%3D%3D';
case 'month,short,age':
return 'CAISBggEEAEYAQ%3D%3D';
case 'year,short,age':
return 'CAISBggFEAEYAQ%3D%3D';
case 'any,long,age':
return 'CAISBBABGAI%3D';
case 'hour,long,age':
return 'CAISBggBEAEYAg%3D%3D';
case 'day,long,age':
return 'CAISBggCEAEYAg%3D%3D';
case 'week,long,age':
return 'CAISBggDEAEYAg%3D%3D';
case 'month,long,age':
return 'CAISBggEEAEYAg%3D%3D';
case 'year,long,age':
return 'CAISBggFEAEYAg%3D%3D';
case 'any,any,views':
return 'CAMSAhAB';
case 'hour,any,views':
return 'CAMSBAgBEAE%3D';
case 'day,any,views':
return 'CAMSBAgCEAE%3D';
case 'week,any,views':
return 'CAMSBAgDEAE%3D';
case 'month,any,views':
return 'CAMSBAgEEAE%3D';
case 'year,any,views':
return 'CAMSBAgFEAE%3D';
case 'any,short,views':
return 'CAMSBBABGAE%3D';
case 'hour,short,views':
return 'CAMSBggBEAEYAQ%3D%3D';
case 'day,short,views':
return 'CAMSBggCEAEYAQ%3D%3D';
case 'week,short,views':
return 'CAMSBggDEAEYAQ%3D%3D';
case 'month,short,views':
return 'CAMSBggEEAEYAQ%3D%3D';
case 'year,short,views':
return 'CAMSBggFEAEYAQ%3D%3D';
case 'any,long,views':
return 'CAMSBBABGAI%3D';
case 'hour,long,views':
return 'CAMSBggBEAEYAg%3D%3D';
case 'day,long,views':
return 'CAMSBggCEAEYAg%3D%3D';
case 'week,long,views':
return 'CAMSBggDEAEYAg%3D%3D';
case 'month,long,views':
return 'CAMSBggEEAEYAg%3D%3D';
case 'year,long,views':
return 'CAMSBggFEAEYAg%3D%3D';
case 'any,any,rating':
return 'CAESAhAB';
case 'hour,any,rating':
return 'CAESBAgBEAE%3D';
case 'day,any,rating':
return 'CAESBAgCEAE%3D';
case 'week,any,rating':
return 'CAESBAgDEAE%3D';
case 'month,any,rating':
return 'CAESBAgEEAE%3D';
case 'year,any,rating':
return 'CAESBAgFEAE%3D';
case 'any,short,rating':
return 'CAESBBABGAE%3D';
case 'hour,short,rating':
return 'CAESBggBEAEYAQ%3D%3D';
case 'day,short,rating':
return 'CAESBggCEAEYAQ%3D%3D';
case 'week,short,rating':
return 'CAESBggDEAEYAQ%3D%3D';
case 'month,short,rating':
return 'CAESBggEEAEYAQ%3D%3D';
case 'year,short,rating':
return 'CAESBggFEAEYAQ%3D%3D';
case 'any,long,rating':
return 'CAESBBABGAI%3D';
case 'hour,long,rating':
return 'CAESBggBEAEYAg%3D%3D';
case 'day,long,rating':
return 'CAESBggCEAEYAg%3D%3D';
case 'week,long,rating':
return 'CAESBggDEAEYAg%3D%3D';
case 'month,long,rating':
return 'CAESBggEEAEYAg%3D%3D';
case 'year,long,rating':
return 'CAESBggFEAEYAg%3D%3D';
default:
}
};
module.exports = { urls, oauth, oauth_reqopts, default_headers, innertube_request_opts, video_details_reqbody, stream_headers, formatVideoData, base64_alphabet, filters };

View File

@@ -2,376 +2,543 @@
const Axios = require('axios');
const Stream = require('stream');
const OAuth = require('./OAuth');
const Utils = require('./Utils');
const Player = require('./Player');
const NToken = require('./NToken');
const Actions = require('./Actions');
const Livechat = require('./Livechat');
const Constants = require('./Constants');
const SigDecipher = require('./SigDecipher');
const EventEmitter = require('events');
const TimeToSeconds = require('time-to-seconds');
const Parser = require('./parser');
const CancelToken = Axios.CancelToken;
const EventEmitter = require('events');
class Innertube extends EventEmitter {
constructor(cookie) {
super();
this.cookie = cookie || '';
return this.init();
const OAuth = require('./core/OAuth');
const Actions = require('./core/Actions');
const Livechat = require('./core/Livechat');
const SessionBuilder = require('./core/SessionBuilder');
const AccountManager = require('./core/AccountManager');
const PlaylistManager = require('./core/PlaylistManager');
const InteractionManager = require('./core/InteractionManager');
const Utils = require('./utils/Utils');
const Request = require('./utils/Request');
const Constants = require('./utils/Constants');
const Proto = require('./proto');
const NToken = require('./deciphers/NToken');
const Signature = require('./deciphers/Signature');
/**
* Innertube instance.
* @namespace
*/
class Innertube {
#player;
/**
* @example
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
*
* @param {object} [config]
* @param {string} [config.gl]
* @param {string} [config.cookie]
* @param {boolean} [config.debug]
*
* @returns {Innertube}
* @constructor
*/
constructor(config) {
this.config = config || {};
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 extract Innertube data: ${response.message}`);
try {
const innertube_data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
if (innertube_data.INNERTUBE_CONTEXT) {
this.context = innertube_data.INNERTUBE_CONTEXT;
this.key = innertube_data.INNERTUBE_API_KEY;
this.id_token = innertube_data.ID_TOKEN;
this.session_token = innertube_data.XSRF_TOKEN;
this.player_url = innertube_data.PLAYER_JS_URL;
this.logged_in = innertube_data.LOGGED_IN;
this.sts = innertube_data.STS;
this.context.client.hl = 'en';
this.context.client.gl = 'US';
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);
}
} else {
return this.init();
}
} catch (err) {
return this.init();
async #init() {
const session = await new SessionBuilder(this.config).build();
this.key = session.key;
this.version = session.api_version;
this.context = session.context;
this.logged_in = false;
this.player_url = session.player.url;
this.sts = session.player.sts;
this.#player = session.player;
/**
* @fires Innertube#auth - fired when signing in to an account.
* @fires Innertube#update-credentials - fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.oauth = new OAuth(this.ev);
if (this.config.cookie) {
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.request = new Request(this);
this.actions = new Actions(this);
this.account = new AccountManager(this.actions);
this.playlist = new PlaylistManager(this.actions);
this.interact = new InteractionManager(this.actions);
return this;
}
signIn(credentials = {}) {
return new Promise(async (resolve, reject) => {
const oauth = new OAuth(credentials);
if (credentials.access_token && credentials.refresh_token) {
let token_validity = await oauth.checkTokenValidity(credentials.access_token, this);
if (token_validity === 'VALID') {
this.access_token = credentials.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.refreshAccessToken(credentials.refresh_token);
oauth.on('refresh-token', (data) => {
this.access_token = data.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
this.emit('update-credentials', {
access_token: data.access_token,
refresh_token: credentials.refresh_token,
status: data.status
});
resolve();
});
}
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.emit('auth', data);
this.access_token = data.access_token;
this.refresh_token = data.refresh_token;
this.logged_in = true;
resolve();
} else {
this.emit('auth', data);
}
});
/**
* 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) => {
this.oauth.init(auth_info);
if (this.oauth.isValidAuthInfo()) {
await this.oauth.checkTokenValidity();
this.#updateCredentials();
return resolve();
}
});
}
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
if (!query) throw new Error('No query was provided');
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/search${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify({ context: this.context, params: Constants.filters(options.period + ',' + options.duration + ',' + options.order), query }), Constants.innertube_request_opts({ session: this })).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not search on YouTube: ${response.message}`);
let content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
let search_response = {};
search_response.search_metadata = {};
search_response.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search_response.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search_response.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search_response.videos = content.map((data) => {
if (!data.videoRenderer) return;
let 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: 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 '
this.ev.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.#updateCredentials();
resolve();
}
};
}).filter((video_block) => video_block !== undefined);
return search_response;
}
async getDetails(id) {
if (!id) return { error: 'Missing video id' };
const data = await this.requestVideoInfo(id, false);
const video_data = Constants.formatVideoData(data, this, false);
if (video_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id: id });
if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return;
video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_id, id);
} else {
video_data.getLivechat = () => {};
}
video_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
video_data.getComments = () => this.getComments(id);
video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' });
return video_data;
}
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.simpleText,
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
}
};
comments_section.comments.push(comment);
});
return comments_section;
}
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Error('Could not fetch 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 = {
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
id: item.gridVideoRenderer.videoId,
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 || [],
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;
}
#updateCredentials() {
this.access_token = this.oauth.getAccessToken();
this.refresh_token = this.oauth.getRefreshToken();
this.logged_in = true;
}
/**
* Signs out of an account.
* @returns {Promise.<{ success: boolean; status_code: number }>}
*/
async signOut() {
if (!this.logged_in) throw new Utils.InnertubeError('You are not signed in');
const response = await this.oauth.revokeAccessToken();
response.success && (this.logged_in = false);
return response;
}
/**
* Searches a given query.
*
* @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 {object} [options.filters] - search filters.
* @param {string} [options.filters.upload_date] - filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
* @param {string} [options.filters.type] - filter results by type, can be: any | video | channel | playlist | movie
* @param {string} [options.filters.duration] - filter videos by duration, can be: any | short | medium | long
* @param {string} [options.filters.sort_by] - filter video results by order, can be: relevance | rating | upload_date | view_count
*
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: object[] } |
* { results: { songs: object[]; videos: object[]; albums: object[]; community_playlists: object[] } }>}
*/
async search(query, options = { client: 'YOUTUBE' }) {
Utils.throwIfMissing({ query });
const response = await this.actions.search({ query, options, client: options.client });
const results = new Parser(this, response.data, {
query,
client: options.client,
data_type: 'SEARCH'
}).parse();
return results;
}
/**
* Retrieves search suggestions for a given query.
*
* @param {string} query - the search query.
* @param {object} [options] - search options.
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
*
* @returns {Promise.<{ query: string; results: string[] }>}
*/
async getSearchSuggestions(query, options = { client: 'YOUTUBE' }) {
Utils.throwIfMissing({ query });
const response = await this.actions.getSearchSuggestions(options.client, query);
if (options.client === 'YTMUSIC' && !response.data.contents) return [];
const suggestions = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH_SUGGESTIONS'
}).parse();
return suggestions;
}
/**
* Retrieves video info.
* @param {string} video_id - the video id.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
*/
async getDetails(video_id) {
Utils.throwIfMissing({ video_id });
const response = await this.actions.getVideoInfo(video_id);
const continuation = await this.actions.next({ video_id });
response.continuation = continuation.data;
const details = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'VIDEO_INFO'
}).parse();
const livechat_ctoken = continuation.data.contents?.twoColumnWatchNextResults
?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData)
.reloadContinuationData.continuation;
details.like = () => this.actions.engage('like/like', { video_id });
details.dislike = () => this.actions.engage('like/dislike', { video_id });
details.removeLike = () => this.actions.engage('like/removelike', { video_id });
details.subscribe = () => this.actions.engage('subscription/subscribe', { channel_id: details.metadata.channel_id });
details.unsubscribe = () => this.actions.engage('subscription/unsubscribe', { channel_id: details.metadata.channel_id });
details.comment = (text) => this.actions.engage('comment/create_comment', { video_id, text });
details.getComments = (sort_by) => this.getComments(video_id, sort_by);
details.getLivechat = () => new Livechat(this, livechat_ctoken, details.metadata.channel_id, video_id);
details.setNotificationPreferences = (type) => this.actions.notifications('modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
return details;
}
/**
* Retrieves comments for a given video.
*
* @param {string} video_id - the video id.
* @param {string} [sort_by] - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: object[]; }>}
*/
async getComments(video_id, sort_by) {
Utils.throwIfMissing({ video_id });
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.next({ ctoken: payload });
const comments = new Parser(this, response.data, {
video_id,
client: 'YOUTUBE',
data_type: 'COMMENTS'
}).parse();
return comments;
}
/**
* Retrieves contents for a given channel. (WIP)
* @param {string} id - channel id
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
async getChannel(id) {
Utils.throwIfMissing({ id });
const response = await this.actions.browse(id);
const channel_info = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'CHANNEL'
}).parse();
return channel_info;
}
/**
* Retrieves watch history.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
async getHistory() {
const response = await this.actions.browse('FEhistory');
const history = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'HISTORY'
}).parse();
return history;
}
/**
* Retrieves home feed (aka recommendations).
* @returns {Promise.<{ videos: { id: string; title: string; description: string; channel: string; metadata: object }[] }>}
*/
async getHomeFeed() {
const response = await this.actions.browse('FEwhat_to_watch');
const homefeed = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'HOMEFEED'
}).parse();
return homefeed;
}
/**
* Retrieves trending content.
* @returns {Promise.<{ now: { content: { title: string; videos: object[]; }[] };
* music: { getVideos: Promise.<Array.<object>>; }; gaming: { getVideos: Promise.<Array.<object>>; };
* movies: { getVideos: Promise.<Array.<object>>; } }>}
*/
async getTrending() {
const response = await this.actions.browse('FEtrending');
const trending = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'TRENDING'
}).parse();
return trending;
}
/**
* @todo finish this
* WIP
*/
async getLibrary() {
const response = await this.actions.browse('FElibrary');
const library = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'LIBRARY'
}).parse();
return library;
}
/**
* Retrieves subscriptions feed.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
async getSubscriptionsFeed() {
const response = await this.actions.browse('FEsubscriptions');
const subsfeed = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'SUBSFEED'
}).parse();
return subsfeed;
}
/**
* Retrieves notifications.
* @returns {Promise.<{ items: { title: string; sent_time: string; channel_name: string; channel_thumbnail: object; video_thumbnail: object; 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 response = await this.actions.notifications('get_notification_menu');
const notifications = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'NOTIFICATIONS'
}).parse();
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);
return notifications;
}
/**
* Retrieves unseen notifications count.
* @returns {Promise.<number>}
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Error('Could not fetch unseen notifications count');
const response = await this.actions.notifications('get_unseen_count');
return response.data.unseenCount;
}
async requestVideoInfo(id, desktop) {
let response;
!desktop && (response = await Axios.get(`${Constants.urls.YT_WATCH_PAGE}?v=${id}&t=8s&pbj=1`, Constants.innertube_request_opts({ session: this, id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/player${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify(Constants.video_details_reqbody(id, this.sts, this.context)), Constants.innertube_request_opts({ session: this, id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error('Could not retrieve watch page info: ' + response.message);
return response.data;
/**
* Retrieves lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>}
*/
async getLyrics(video_id) {
Utils.throwIfMissing({ video_id });
const continuation = await this.actions.next({ video_id: video_id, client: 'YTMUSIC' });
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
const response = await this.actions.browse(lyrics_tab.endpoint?.browseEndpoint.browseId, { client: 'YTMUSIC' });
if (!response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false);
return lyrics.runs[0].text;
}
download(id, options = {}) {
if (!id) throw new Error('Missing video id');
/**
* Retrieves the contents of a given playlist.
*
* @param {string} playlist_id - the id of the playlist.
* @param {object} options - `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' }) {
Utils.throwIfMissing({ playlist_id });
const response = await this.actions.browse(`VL${playlist_id}`, { client: options.client });
const playlist = new Parser(this, response.data, {
client: options.client,
data_type: 'PLAYLIST'
}).parse();
return playlist;
}
/**
* 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 Signature(format.url, this.#player.signature_decipher).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_decipher, url_components.searchParams.get('n')).transform());
}
format.url = url_components.toString();
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
format.has_video = !!format.qualityLabel;
delete format.cipher;
delete format.signatureCipher;
});
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);
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 || !streams.length &&
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
bitrates = streams.map((format) => format.bitrate);
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
return { selected_format: format, formats };
}
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} video_id - video id
* @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: object; formats: object[] }>}
*/
async getStreamingData(video_id, options = {}) {
Utils.throwIfMissing({ video_id });
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
const data = await this.actions.getVideoInfo(video_id);
const streaming_data = this.#chooseFormat(options, data);
if (!streaming_data.selected_format)
throw new Utils.NoStreamingDataError('Could not find any suitable format.', { video_id, options });
return streaming_data;
}
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} video_id - video id
* @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
* @param {object} [options.range] - download range, indicates which bytes should be downloaded.
* @param {number} options.range.start - the beginning of the range.
* @param {number} options.range.end - the end of the range.
*
* @return {Stream.PassThrough}
*/
download(video_id, options = {}) {
Utils.throwIfMissing({ video_id });
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
let cancel;
let cancelled = false;
const cpn = Utils.generateRandomString(16);
const stream = new Stream.PassThrough();
this.requestVideoInfo(id, true).then(async (video_data) => {
let formats = [];
this.actions.getVideoInfo(video_id, cpn).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 });
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);
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.context.client.clientVersion, this.player).decipher();
} else {
const url_components = new URL(format.url);
url_components.searchParams.set('cver', this.context.client.clientVersion);
url_components.searchParams.set('ratebypass', 'yes');
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;
const video_details = Constants.formatVideoData(video_data, this, true);
let url;
let bitrates;
let filtered_streams;
switch (options.type) {
case 'video':
filtered_streams = formats.filter((format) => format.has_video && !format.has_audio);
break;
case 'audio':
filtered_streams = formats.filter((format) => format.has_audio && !format.has_video);
break;
case 'videoandaudio':
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
break;
default:
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
break;
}
if (options.type != 'videoandaudio') {
let streams;
options.type != 'audio' && (streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
(streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4')));
streams == undefined || streams.length == 0 && (streams = filtered_streams.filter((format) => format.quality == 'medium'));
bitrates = streams.map((format) => format.bitrate);
url = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
}
const selected_format = options.type == 'videoandaudio' ? filtered_streams[0] : url;
if (!selected_format) {
if (!format)
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
} else {
stream.emit('info', { video_details, selected_format, formats });
}
if (options.type == 'videoandaudio') {
const response = await Axios.get(selected_format.url, {
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
stream.emit('info', { video_details, selected_format: format, formats });
if (options.type == 'videoandaudio' && !options.range) {
const response = await Axios.get(`${format.url}&cpn=${cpn}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.stream_headers()
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
@@ -382,39 +549,50 @@ class Innertube extends EventEmitter {
}
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', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
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) => {
if (cancelled) {
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
} else {
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 = 0;
let chunk_end = chunk_size;
let chunk_start = (options.range && options.range.start || 0);
let chunk_end = (options.range && options.range.end || chunk_size);
let downloaded_size = 0;
let end = false;
let must_end = false;
stream.emit('start');
const downloadChunk = async () => {
if (chunk_end >= selected_format.contentLength) end = true;
const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, {
(chunk_end >= format.contentLength || options.range) && (must_end = true);
options.range && (format.contentLength = options.range.end);
const response = await Axios.get(`${format.url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.stream_headers()
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
@@ -424,29 +602,40 @@ class Innertube extends EventEmitter {
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-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) => {
if (cancelled) {
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
} else {
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 (!end) {
if (!must_end && !options.range) {
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
downloadChunk();
}
});
response.data.pipe(stream, { end });
response.data.pipe(stream, { end: must_end });
};
downloadChunk();
}
});

View File

@@ -1,135 +0,0 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
class NToken {
constructor(raw_code) {
this.raw_code = raw_code;
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
}
transform(n) {
let n_token = n.split('');
try {
let transformations = this.getTransformationData(this.raw_code);
// Identifies the necessary transformation data and emulates them accordingly.
transformations = transformations.map((el) => {
if (el != null && typeof el != 'number') {
const is_reverse_base64 = el.includes('case 65:');
if (el.includes('function(d){for(var')) {
el = (arr) => this.pushSplice(arr);
} else if (el.includes('d.push(e)')) {
el = (arr, item) => this.push(arr, item);
} else if (el.includes('d.reverse()')) {
el = (arr) => this.reverse(arr);
} else if (el.includes('d.length;d.splice(e,1)')) {
el = (arr, index) => this.spliceOnce(arr, index);
} else if (el.includes('d[0])[0])')) {
el = (arr, index) => this.spliceTwice(arr, index);
} else if (el.includes('reverse().forEach')) {
el = (arr, index) => this.spliceReverseUnshift(arr, index);
} else if (el.includes('f=d[0];d[0]')) {
el = (arr, index) => this.swapFirstItem(arr, index);
} else if (el.includes('unshift(d.pop())')) {
el = (arr, index) => this.unshiftPop(arr, index);
} else if (el.includes('switch')) {
el = (arr, e) => this.translateAB(arr, e, is_reverse_base64);
} else if (el === 'b') {
el = n_token;
}
}
return el;
});
// Fills the null placeholders with a copy of the transformations array.
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
// Parses and emulates calls to functions of the transformations array.
let transformation_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
transformation_calls.forEach((data) => {
const index = data.index;
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
transformations[index](transformations[param_index[0]], transformations[param_index[1]]);
});
} catch (err) {
return n;
}
return n_token.join('');
}
getTransformationData() {
// These variable names have always been the same since earlier player versions, so it should not be a problem for now.
let transformation_data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
transformation_data = transformation_data
.replace(/function\(d,e\)/g, '"function(d,e)')
.replace(/function\(d\)/g, '"function(d)')
.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])}');
return JSON.parse(transformation_data);
}
translateAB(arr, index, is_reverse_base64) {
let characters = is_reverse_base64 && Constants.base64_alphabet.reverse || Constants.base64_alphabet.normal;
arr.forEach(function(char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
}, index.split(''));
}
unshiftPop(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
for (; index--;) {
arr.unshift(arr.pop());
}
}
swapFirstItem(arr, index) {
let oldValue = arr[0];
index = (index % arr.length + arr.length) % arr.length;
arr[0] = arr[index];
arr[index] = oldValue;
}
spliceReverseUnshift(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(-index).reverse().forEach((f) => arr.unshift(f));
}
spliceOnce(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(index, 1);
}
spliceTwice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(0, 1, arr.splice(index, 1, arr[0])[0]);
}
pushSplice(arr) {
for (let index = arr.length; index;)
arr.push(arr.splice(--index, 1)[0]);
}
push(arr, item) {
arr.push(item);
}
reverse(arr) {
arr.reverse();
}
}
module.exports = NToken;

View File

@@ -1,171 +0,0 @@
'use strict';
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
const EventEmitter = require('events');
const Uuid = require("uuid");
class OAuth extends EventEmitter {
constructor(creds) {
super();
// Default interval between requests when waiting for authorization.
this.refresh_interval = 5;
// OAuth URLs:
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`;
// Used to check whether an access token is valid or not.
this.guide_url = `${Constants.urls.YT_BASE_URL}/youtubei/v1/guide`;
// These are always the same, so we shouldn't have any problems for now.
this.model_name = Constants.oauth.model_name;
this.grant_type = Constants.oauth.grant_type;
this.scope = Constants.oauth.scope;
// Script that contains important information such as client id and client secret.
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
// Used to find the credentials inside the script.
this.identity_regex = /var .+?=\"(?<id>.+?)\",[.|\s].?=\"(?<secret>.+?)\"/;
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
this.requestAuthCode();
}
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_reqopts).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth 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: 'The 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 {
this.emit('auth', {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}
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_reqopts).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;
// Keeps requesting at a specific rate until the authorization is granted or denied.
this.waitForAuth(response.data.device_code);
}
async getClientIdentity() {
// The first 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_reqopts).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identify: ${yttv_response.message}`);
// Here we get 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 identify: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, '=function(){var a=window.environment', '(function()');
const client_identity = identity_function.match(this.identity_regex).groups;
return client_identity;
}
async refreshAccessToken(refresh_token) {
const identity = await this.getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
if (response instanceof Error)
return this.emit('refresh-token', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
this.emit('refresh-token', {
access_token: response.data.access_token,
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
}
async checkTokenValidity(access_token, session) {
let headers = Constants.innertube_request_opts({ session }).headers;
headers.authorization = `Bearer ${access_token}`;
const response = await Axios.post(this.guide_url, JSON.stringify({ context: session.context }), { headers }).catch((error) => error);
if (response instanceof Error) return 'INVALID';
return 'VALID';
}
}
module.exports = OAuth;

View File

@@ -1,46 +0,0 @@
'use strict';
const fs = require('fs');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
class Player {
constructor(innertube_session) {
this.session = innertube_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.getSigDecipherCode(player_data);
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 {
// 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.getSigDecipherCode(response.data);
this.getNEncoder(response.data);
}
}
getSigDecipherCode(data) {
const manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
}
getNEncoder(data) {
this.ntoken_sc = `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
}
module.exports = Player;

View File

@@ -1,78 +0,0 @@
'use strict';
const NToken = require('./NToken');
const QueryString = require('querystring');
class SigDecipher {
constructor(url, cver, player) {
this.url = url;
this.cver = cver;
this.player = player;
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
}
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 !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
url_components.searchParams.set('cver', this.cver);
url_components.searchParams.set('ratebypass', 'yes');
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
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,86 +0,0 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
return new UserAgent(/Android/).data;
case 'desktop':
return new UserAgent({ deviceCategory: 'desktop' }).data;
default:
}
}
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(' ');
}
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");
}
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'));
}
function generateMessageParams(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');
}
function generateCommentParams(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'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref };

251
lib/core/AccountManager.js Normal file
View File

@@ -0,0 +1,251 @@
'use strict';
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const Proto = require('../proto');
/** @namespace */
class AccountManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
/** @namespace */
this.channel = {
/**
* Edits channel name.
*
* @param {string} new_name
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editName: (new_name) => this.#actions.channel('channel/edit_name', { new_name }),
/**
* Edits channel description.
*
* @param {string} new_description
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editDescription: (new_description) => this.#actions.channel('channel/edit_description', { new_description }),
/**
* Retrieves basic channel analytics.
* @borrows AccountManager#getAnalytics as getBasicAnalytics
*/
getBasicAnalytics: () => this.getAnalytics()
}
/** @namespace */
this.settings = {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', option),
/**
* Recommended content notifications.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setRecommendedVideos: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', option),
/**
* Notify about activity on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setChannelActivity: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', option),
/**
* Notify about replies to your comments.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setCommentReplies: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', option),
/**
* Notify when others mention your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setMentions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', option),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSharedContent: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', option)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptionsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', option),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSavedPlaylistsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', option)
}
}
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @private
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async #setSetting(setting_id, type, new_value) {
Utils.throwIfMissing({ setting_id, type, new_value });
const values = { ON: true, OFF: false };
if (!values.hasOwnProperty(new_value))
throw new Utils.InnertubeError('Invalid option', { option: new_value, available_options: Object.keys(values) });
const response = await this.#actions.browse(type);
const contents = ({
SPaccount_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
SPaccount_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
})[type.trim()]();
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 this.#actions.account('account/set_setting', {
new_value: type == 'SPaccount_privacy' ? !values[new_value] : values[new_value],
setting_item_id
});
return set_setting;
}
/**
* Retrieves channel info.
* @returns {Promise.<{ name: string; email: string; channel_id: string; subscriber_count: string; photo: object[]; }>}
*/
async getInfo() {
const response = await this.#actions.account('account/accounts_list', { client: 'ANDROID' });
const account_item_section_renderer = Utils.findNode(response.data, 'contents', 'accountItem', 8, false);
const profile = account_item_section_renderer.accountItem.serviceEndpoint.signInEndpoint.directSigninUserProfile;
const name = profile.accountName;
const email = profile.email;
const photo = profile.accountPhoto.thumbnails;
const subscriber_count = account_item_section_renderer.accountItem.accountByline.runs.map((run) => run.text).join('');
const channel_id = response.data.contents[0].accountSectionListRenderer.footers[0].accountChannelRenderer.navigationEndpoint.browseEndpoint.browseId;
return { name, email, channel_id, subscriber_count, photo };
}
/**
* Retrieves time watched statistics.
* @returns {Promise.<[{ title: string; time: string; }]>}
*/
async getTimeWatched() {
const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' });
const rows = Utils.findNode(response.data, 'contents', 'statRowRenderer', 11, false);
const stats = rows.map((row) => {
const renderer = row.statRowRenderer;
if (renderer) {
return {
title: renderer.title.runs.map((run) => run.text).join(''),
time: renderer.contents.runs.map((run) => run.text).join('')
}
}
}).filter((stat) => stat);
return stats;
}
/**
* Retrieves basic channel analytics.
*
* @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string;
* comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string;
* published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>}
*/
async getAnalytics() {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.channel_id);
const action = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const contents = Utils.findNode(action.data, 'contents', 'elementRenderer', 11, false);
const analytics = {
metrics: {},
top_content: {}
}
contents.forEach((el) => {
const element = el.elementRenderer.newElement;
const model = element.type.componentType.model;
const key = Object.keys(model)[0];
switch (key) {
case 'analyticsRootModel':
const sections = model.analyticsRootModel.analyticsKeyMetricsData.dataModel.sections;
analytics.metrics = sections.map((section) => ({
title: section.title,
subtitle: section.subtitle,
metric_value: section.metricValue,
comparison_indicator: section.comparisonIndicator,
series_configuration: section.seriesConfiguration
}));
break;
case 'analyticsVodCarouselCardModel':
const video_carousel = model.analyticsVodCarouselCardModel.videoCarouselData;
analytics.top_content = video_carousel?.videos.map((video) => ({
title: video.videoTitle,
metadata: {
views: video.videoDescription.split('·')[0].trim(),
published: video.videoDescription.split('·')[1].trim(),
thumbnails: video.thumbnailDetails.thumbnails,
duration: video.formattedLength,
is_short: video.isShort
}
})) || [];
break;
default:
break;
}
});
return analytics;
}
}
module.exports = AccountManager;

587
lib/core/Actions.js Normal file
View File

@@ -0,0 +1,587 @@
'use strict';
const Uuid = require('uuid');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/** namespace **/
class Actions {
#session;
#request;
/**
* @param {Innertube} session
* @constructor
*/
constructor(session) {
this.#session = session;
this.#request = session.request;
}
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param {string} id - browseId or a continuation token
* @param {object} args - additional arguments
* @param {string} [args.params]
* @param {boolean} [args.is_ytm]
* @param {boolean} [args.is_ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
async browse(id, args = {}) {
if (this.#needsLogin(id) && !this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {};
args.params &&
(data.params = args.params);
args.is_ctoken &&
(data.continuation = id) ||
(data.browseId = id);
args.client &&
(data.client = args.client);
const response = await this.#request.post('/browse', data);
return response;
}
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*
* @param {string} action
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.comment_id]
* @param {string} [args.comment_action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async engage(action, args = {}) {
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data.target = {
videoId: args.video_id
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data.channelIds = [args.channel_id];
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const target_action = ({
like: () => Proto.encodeCommentActionParams(5, args),
dislike: () => Proto.encodeCommentActionParams(4, args),
translate: () => Proto.encodeCommentActionParams(22, args)
})[args.comment_action]();
data.actions = [ target_action ];
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Covers endpoints related to account management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_value]
* @param {string} [args.setting_item_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
async account(action, args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = { client: args.client };
switch (action) {
case 'account/set_setting':
data.newValue = { boolValue: args.new_value };
data.settingItemId = args.setting_item_id;
break;
case 'account/accounts_list':
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Endpoint used for search.
*
* @param {object} args
* @param {string} [args.query]
* @param {object} [args.options]
* @param {string} [args.options.period]
* @param {string} [args.options.duration]
* @param {string} [args.options.order]
* @param {string} [args.client]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async search(args = {}) {
const data = {};
args.query &&
(data.query = args.query);
args.ctoken &&
(data.continuation = args.ctoken);
args.client == 'YOUTUBE' &&
(data.params = Proto.encodeSearchFilter(args.options.filters));
args.client &&
(data.client = args.client);
const response = await this.#request.post('/search', data);
return response;
}
/**
* Endpoint used fo Shorts' sound search.
*
* @param {object} args
* @param {string} args.query
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async searchSound(args = {}) {
const data = {
query: args.query,
client: 'ANDROID',
};
const response = await this.#request.post('/sfv/search', data);
return response;
}
/**
* Channel management endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_name]
* @param {string} [args.new_description]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async channel(action, args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = { client: args.client || 'ANDROID' };
switch (action) {
case 'channel/edit_name':
data.givenName = args.new_name;
break;
case 'channel/edit_description':
data.description = args.new_description;
break;
case 'channel/get_profile_editor':
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Covers endpoints used for playlist management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.title]
* @param {string} [args.ids]
* @param {string} [args.playlist_id]
* @param {string} [args.action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async playlist(action, args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'playlist/create':
data.title = args.title;
data.videoIds = args.ids;
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
data.playlistId = args.playlist_id;
data.actions = args.ids.map((id) => ({
'ACTION_ADD_VIDEO': {
action: args.action,
addedVideoId: id
},
'ACTION_REMOVE_VIDEO': {
action: args.action,
setVideoId: id
}
})[args.action]);
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Covers endpoints used for notifications management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.pref]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async notifications(action, args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'modify_channel_preference':
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
break;
case 'get_notification_menu':
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
args.ctoken && (data.ctoken = args.ctoken);
break;
case 'get_unseen_count':
// doesn't require any parameter
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/notification/${action}`, data);
return response;
}
/**
* Covers livechat endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.text]
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async livechat(action, args = {}) {
const data = {};
switch (action) {
case 'live_chat/get_live_chat':
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = Uuid.v4();
data.richMessage = {
textSegments: [{ text: args.text }]
}
break;
case 'live_chat/get_item_context_menu':
// note: this is currently broken due to a recent refactor
break;
case 'live_chat/moderate':
data.params = args.params;
break;
case 'updated_metadata':
data.videoId = args.video_id;
args.ctoken && (data.continuation = args.ctoken);
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Endpoint used to retrieve video thumbnails.
*
* @param {object} args
* @param {string} args.video_id
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async thumbnails(args = {}) {
const data = {
client: 'ANDROID',
videoId: args.video_id
};
const response = await this.#request.post('/thumbnails', data);
return response;
}
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*
* @param {string} action
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async geo(action, args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#request.post(`/geo/${action}`, data);
return response;
}
/**
* Covers endpoints used to report content.
*
* @param {string} action
* @param {object} args
* @param {object} [args.action]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async flag(action, args) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'flag/flag':
data.action = args.action;
break;
case 'flag/get_form':
data.params = args.params;
break;
default:
throw new Utils.InnertubeError('Action not implemented', action);
}
const response = await this.#request.post(`/${action}`, data);
return response;
}
/**
* Covers specific YouTube Music endpoints.
*
* @param {string} action
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async music(action, args) {
const data = {
input: args.input || '',
client: 'YTMUSIC'
};
const response = await this.#request.post(`/music/${action}`, data);
return response;
}
/**
* Mostly used for pagination and specific operations.
*
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async next(args = {}) {
const data = {};
args.ctoken &&
(data.continuation = args.ctoken);
args.video_id &&
(data.videoId = args.video_id);
args.client &&
(data.client == args.client);
const response = await this.#request.post('/next', data);
return response;
}
/**
* Used to retrieve video info.
*
* @param {string} id
* @param {string} [cpn]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async getVideoInfo(id, cpn) {
const data = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
referer: 'https://www.youtube.com',
currentUrl: '/watch?v=' + id,
autonavState: 'STATE_OFF',
signatureTimestamp: this.#session.sts,
autoCaptionsDefaultOn: false,
html5Preference: 'HTML5_PREF_WANTS',
lactMilliseconds: '-1'
}
},
videoId: id
};
cpn && (data.cpn = cpn);
const response = await this.#request.post('/player', data);
return response.data;
}
/**
* Covers search suggestion endpoints.
*
* @param {string} client
* @param {string} input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async getSearchSuggestions(client, query) {
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
throw new Utils.InnertubeError('Invalid client', client);
const response = await ({
YOUTUBE: () => this.#request({
url: 'search',
baseURL: Constants.URLS.YT_SUGGESTIONS,
params: {
q: query,
ds: 'yt',
client: 'youtube',
xssi: 't',
oe: 'UTF',
gl: this.#session.context.client.gl,
hl: this.#session.context.client.hl
}
}),
YTMUSIC: () => this.music('get_search_suggestions', { input: query })
}[client])();
return response;
}
/**
* Endpoint used to retrieve user mention suggestions.
*
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async getUserMentionSuggestions(args = {}) {
if (!this.#session.logged_in)
throw new Utils.InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#request.post('get_user_mention_suggestions', data);
return response;
}
#needsLogin(id) {
return [
'FElibrary', 'FEhistory', 'FEsubscriptions',
'SPaccount_notifications', 'SPaccount_privacy',
'SPtime_watched'
].includes(id);
}
}
module.exports = Actions;

View File

@@ -0,0 +1,134 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class InteractionManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions) {
this.#actions = actions;
}
/**
* Likes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async like(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_id });
return action;
}
/**
* Dislikes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async dislike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_id });
return action;
}
/**
* Removes a like/dislike.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async removeLike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.actions.engage('like/removelike', { video_id });
return action;
}
/**
* Subscribes to a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async subscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
return action;
}
/**
* Unsubscribes from a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async unsubscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
return action;
}
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async comment(video_id, text) {
Utils.throwIfMissing({ video_id, text });
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
return action;
}
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param {string} text
* @param {string} target_language - an ISO language code
* @param {object} [args] - optional arguments
* @param {string} [args.video_id]
* @param {string} [args.comment_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; translated_content: string; data: object; }>}
*/
async translate(text, target_language, args = {}) {
Utils.throwIfMissing({ text, target_language });
const response = await await this.#actions.engage('comment/perform_comment_action', {
video_id: args.video_id,
comment_id: args.comment_id,
target_language: target_language,
comment_action: 'translate',
text
});
const translated_content = Utils.findNode(response.data, 'frameworkUpdates', 'content', 7, false);
return {
success: response.success,
status_code: response.status_code,
translated_content: translated_content.content,
data: response.data
}
}
/**
* 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: number; data: object; }>}
*/
async setNotificationPreferences(channel_id, type) {
Utils.throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
return action;
}
}
module.exports = InteractionManager;

View File

@@ -1,13 +1,14 @@
'use strict';
const Axios = require('axios');
const Actions = require('./Actions');
const Constants = require('./Constants');
const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
if (!token)
throw new Error('Could not retrieve livechat data');
this.ctoken = token;
this.session = session;
this.video_id = video_id;
@@ -19,12 +20,55 @@ class Livechat extends EventEmitter {
this.poll_intervals_ms = 1000;
this.running = true;
this.poll();
this.#poll();
}
enqueueActionGroup(group) {
async #poll() {
if (!this.running) return;
const livechat = await this.session.actions.livechat('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.ctoken = this.metadata_ctoken;
const updated_metadata = await this.session.actions.livechat('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
if (!action.addChatItemAction) return; //TODO: handle different action types
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
if (!message_content) return;
@@ -43,59 +87,17 @@ class Livechat extends EventEmitter {
});
}
async poll() {
if (!this.running) return;
let data;
data = { context: this.session.context, continuation: this.ctoken };
const livechat = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`);
const continuation_contents = livechat.data.continuationContents;
const action_group = continuation_contents.liveChatContinuation.actions;
this.enqueueActionGroup(action_group);
// Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its livechat js player.
this.message_queue.forEach((message, index) => {
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 = [];
data = { context: this.session.context, videoId: this.video_id };
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
const updated_metadata = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.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,
dislikes: metadata[2].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);
}
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 });
const message = await this.session.actions.livechat('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 } });
const menu = await this.session.actions.livechat('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 });
const cmd = await this.session.actions.livechat('live_chat/moderate', { params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
if (!cmd.success) return cmd;
return { success: true, status_code: cmd.status_code };
@@ -104,7 +106,7 @@ class Livechat extends EventEmitter {
return {
success: true,
status_code: message.status_code,
deleteMessage,
deleteMessage: deleteMessage,
message_data: {
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
author: {
@@ -118,8 +120,11 @@ class Livechat extends EventEmitter {
};
}
async blockUser(msg_params) {
/* TODO: Implement this */
/**
* Blocks a user.
* @todo Implement this method
*/
async blockUser() {
throw new Error('Not implemented');
}

244
lib/core/OAuth.js Normal file
View File

@@ -0,0 +1,244 @@
'use strict';
const Axios = require('axios');
const Constants = require('../utils/Constants');
const Uuid = require('uuid');
/** @namespace */
class OAuth {
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#polling_interval = 5;
#ev = null;
/**
* @param {EventEmitter} ev
* @constructor
*/
constructor(ev) {
this.#ev = ev;
}
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
async init(auth_info) {
this.#auth_info = auth_info;
if (!auth_info.access_token) {
this.#requestUserCode();
}
}
/**
* Asks the OAuth server for a user code
* and verification URL.
*
* @returns {Promise.<void>}
*/
async #requestUserCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
const data = {
client_id: this.client_id,
scope: Constants.OAUTH.SCOPE,
device_id: Uuid.v4(),
model_name: Constants.OAUTH.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.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
this.#ev.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
verification_url: response.data.verification_url
});
this.#polling_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: Constants.OAUTH.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.#ev.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.#ev.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.#ev.emit('auth', {
error: 'The user code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestUserCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('auth', {
credentials,
status: 'SUCCESS'
});
}
}, 1000 * this.#polling_interval);
}
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
async checkTokenValidity() {
if (this.shouldRefreshToken()) {
await this.#refreshAccessToken();
}
}
/**
* Gets a new access token using a refresh token.
* @returns {Promise.<void>}
*/
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)
return this.#ev.emit('update-credentials', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('update-credentials', {
credentials,
status: 'SUCCESS'
});
}
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
async revokeAccessToken() {
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status || 0
}
}
/**
* 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}/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 = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
const response = await Axios.get(script_url).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(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
/**
* Returns the access token.
* @returns {string}
*/
getAccessToken() {
return this.#auth_info.access_token;
}
/**
* Returns the refresh token.
* @returns {string}
*/
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info format is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {
return this.#auth_info.hasOwnProperty('access_token')
&& this.#auth_info.hasOwnProperty('refresh_token')
&& this.#auth_info.hasOwnProperty('expires');
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
shouldRefreshToken() {
const timestamp = new Date(this.#auth_info.expires).getTime();
return new Date().getTime() > timestamp;
}
}
module.exports = OAuth;

127
lib/core/Player.js Normal file
View File

@@ -0,0 +1,127 @@
'use strict';
const os = require('os');
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/** @namespace */
class Player {
#player_id;
#player_url;
#player_path;
#ntoken_decipher_sc;
#signature_decipher_sc;
#signature_timestamp;
#cache_dir;
/**
* Represents the YouTube Web player script.
* @param {string} id - the id of the player.
* @constructor
*/
constructor(id) {
this.#player_id = id;
this.#cache_dir = `${os.tmpdir()}/cache`;
this.#player_url = Constants.URLS.YT_BASE + '/s/player/' + this.#player_id + '/player_ias.vflset/en_US/base.js';
this.#player_path = `${this.#cache_dir}/${this.#player_id}.js`;
}
async init() {
if (this.isCached()) {
const player_data = Fs.readFileSync(this.#player_path).toString();
this.#signature_timestamp = this.#extractSigTimestamp(player_data);
this.#signature_decipher_sc = this.#extractSigDecipherSc(player_data);
this.#ntoken_decipher_sc = this.#extractNTokenSc(player_data);
} else {
const response = await Axios.get(this.#player_url, { headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Utils.InnertubeError('Could not download js player', { player_id: this.#player_id });
this.#signature_timestamp = this.#extractSigTimestamp(response.data);
this.#signature_decipher_sc = this.#extractSigDecipherSc(response.data);
this.#ntoken_decipher_sc = this.#extractNTokenSc(response.data);
try {
// Delete the old player
Fs.existsSync(this.#cache_dir) &&
Fs.rmSync(this.#cache_dir, { recursive: true });
// Cache the current player
Fs.mkdirSync(this.#cache_dir, { recursive: true });
Fs.writeFileSync(this.#player_path, response.data);
} finally { /* do nothing */ }
}
return this;
}
/**
* Returns the current player's url.
* @readonly
* @returns {string}
*/
get url() {
return this.#player_url;
}
/**
* Returns the signature timestamp.
* @readonly
* @returns {string}
*/
get sts() {
return this.#signature_timestamp;
}
/**
* Returns the n-token decipher algorithm.
* @readonly
* @returns {string}
*/
get ntoken_decipher() {
return this.#ntoken_decipher_sc;
}
/**
* Returns the signature decipher algorithm.
* @readonly
* @returns {string}
*/
get signature_decipher() {
return this.#signature_decipher_sc;
}
/**
* Extracts the signature timestamp from the player source code.
* @returns {number}
*/
#extractSigTimestamp(data) {
return parseInt(Utils.getStringBetweenStrings(data, 'signatureTimestamp:', ','));
}
/**
* Extracts the signature decipher algorithm.
* @returns {string}
*/
#extractSigDecipherSc(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;
}
/**
* Extracts the n-token decipher algorithm.
* @returns {string}
*/
#extractNTokenSc(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
isCached() {
return Fs.existsSync(this.#player_path);
}
}
module.exports = Player;

115
lib/core/PlaylistManager.js Normal file
View File

@@ -0,0 +1,115 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class PlaylistManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
}
/**
* Creates a playlist.
*
* @param {string} title
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async create(title, video_ids) {
Utils.throwIfMissing({ title, video_ids });
const response = await this.#actions.playlist('playlist/create', { title, ids: video_ids });
return {
success: response.success,
status_code: response.status_code,
playlist_id: response.data.playlistId,
data: response.data
}
}
/**
* Deletes a given playlist.
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async delete(playlist_id) {
Utils.throwIfMissing({ playlist_id });
const response = await this.#actions.playlist('playlist/delete', { playlist_id });
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Adds videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async addVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: video_ids,
action: 'ACTION_ADD_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Removes videos from a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async removeVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const plinfo = await this.#actions.browse(`VL${playlist_id}`);
const list = Utils.findNode(plinfo.data, 'contents', 'contents', 13, false);
if (!list.isEditable) throw new Utils.InnertubeError('This playlist cannot be edited.', playlist_id);
const videos = list.contents.filter((item) => video_ids.includes(item.playlistVideoRenderer.videoId));
const set_video_ids = videos.map((video) => video.playlistVideoRenderer.setVideoId);
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: set_video_ids,
action: 'ACTION_REMOVE_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
}
module.exports = PlaylistManager;

146
lib/core/SessionBuilder.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
const Axios = require('axios');
const Player = require('./Player');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const UserAgent = require('user-agents');
/** @namespace */
class SessionBuilder {
#config;
#key;
#client_name;
#client_version;
#api_version;
#remote_host;
#context;
#player;
/**
* @param {object} config
* @constructor
*/
constructor(config) {
this.#config = config;
}
async build() {
const data = await Promise.all([
this.#getYtConfig(),
this.#getPlayerId()
]);
const ytcfg = data[0][0][2];
this.#key = ytcfg[1];
this.#api_version = `v${ytcfg[0][0][6]}`;
this.#client_name = Constants.CLIENTS.WEB.NAME;
this.#client_version = ytcfg[0][0][16];
this.#remote_host = ytcfg[0][0][3];
this.#player = await new Player(data[1]).init();
this.#context = this.#buildContext();
return this;
}
/**
* Builds a valid context object.
* @returns
*/
#buildContext() {
const user_agent = new UserAgent({ deviceCategory: 'desktop' });
const id = Utils.generateRandomString(11);
const timestamp = Math.floor(Date.now() / 1000);
const visitor_data = Proto.encodeVisitorData(id, timestamp);
const context = {
client: {
hl: 'en',
gl: this.#config.gl || 'US',
remoteHost: this.#remote_host,
deviceMake: user_agent.vendor,
deviceModel: user_agent.platform,
visitorData: visitor_data,
userAgent: user_agent.toString(),
clientName: this.#client_name,
clientVersion: this.#client_version,
originalUrl: Constants.URLS.YT_BASE
},
user: { lockedSafetyMode: false },
request: { useSsl: true }
}
return context;
}
/**
* Retrieves initial configuration such as keys,
* client data, etc.
* @returns Promise.<object>
*/
async #getYtConfig() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/sw.js_data`).catch((err) => err);
if (response instanceof Error)
throw new Utils.InnertubeError('Could not retrieve configuration data', {
status_code: response?.response?.status || 0,
message: response.message
});
return JSON.parse(response.data.replace(')]}\'', ''));
}
/**
* Retrives the YouTube player id.
* @returns {Promise.<string>
*/
async #getPlayerId() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/iframe_api`).catch((err) => err);
if (response instanceof Error)
throw new Utils.InnertubeError('Could not retrieve js player id', {
status_code: response?.response?.status || 0,
message: response.message
});
return Utils.getStringBetweenStrings(response.data, 'player\\/', '\\/');
}
/** @readonly */
get key() {
return this.#key;
}
/** @readonly */
get context() {
return this.#context;
}
/** @readonly */
get api_version() {
return this.#api_version;
}
/** @readonly */
get client_version() {
return this.#client_version;
}
/** @readonly */
get client_name() {
return this.#client_name;
}
/** @readonly */
get player() {
return this.#player;
}
}
module.exports = SessionBuilder;

142
lib/deciphers/NToken.js Normal file
View File

@@ -0,0 +1,142 @@
'use strict';
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
class NToken {
constructor(raw_code, n) {
this.n = n;
this.raw_code = raw_code;
}
/**
* Solves throttling challange by transforming the n token.
* @returns {string}
*/
transform() {
let n_token = this.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(Constants.NTOKEN_REGEX.PLACEHOLDERS)].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(Constants.NTOKEN_REGEX.CALLS)].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(new Utils.ParsingError('Could not transform n-token, download may be throttled.', {
original_token: this.n,
stack: err.stack
}));
return this.n;
}
return n_token.join('');
}
#getFunc(el) {
return el.match(Constants.NTOKEN_REGEX.FUNCTIONS);
}
/**
* Takes the n-transform data, refines it, and then returns a readable json array.
* @returns {Array}
*/
#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

@@ -0,0 +1,91 @@
'use strict';
const Constants = require('../utils/Constants');
const QueryString = require('querystring');
class Signature {
constructor(url, sig_decipher_sc) {
this.url = url;
this.sig_decipher_sc = sig_decipher_sc;
}
/**
* Deciphers signature.
* @returns {string}
*/
decipher() {
let actions;
const args = QueryString.parse(this.url);
const signature = args.s.split('');
const functions = this.#getFunctions();
/**
* Decides what function should be used to modify the
* the signature.
*/
while ((actions = Constants.SIG_REGEX.ACTIONS.exec(this.sig_decipher_sc)) !== null) {
const action = actions.groups;
switch (action.name) {
case functions[0]:
this.#reverse(signature);
break;
case functions[1]:
this.#splice(signature, action.param);
break;
case functions[2]:
this.#swap(signature, action.param);
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();
}
/**
* Extracts the functions used to modify the signature
* and returns them in the correct order.
*
* @returns {Array.<string>}
*/
#getFunctions() {
let func;
let functions = [];
while ((func = Constants.SIG_REGEX.FUNCTIONS.exec(this.sig_decipher_sc)) !== null) {
if (func[0].includes('reverse')) {
functions[0] = func[1];
} else if (func[0].includes('splice')) {
functions[1] = func[1];
} else {
functions[2] = func[1];
}
}
return functions;
}
#swap(arr, index) {
let origArrI = arr[0];
arr[0] = arr[index % arr.length];
arr[index % arr.length] = origArrI;
}
#splice(arr, end) {
arr.splice(0, end);
}
#reverse(arr) {
arr.reverse();
}
}
module.exports = Signature;

589
lib/parser/index.js Normal file
View File

@@ -0,0 +1,589 @@
'use strict';
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const YTDataItems = require('./youtube');
const YTMusicDataItems = require('./ytmusic');
const Proto = require('../proto');
class Parser {
constructor(session, data, args = {}) {
this.data = data;
this.session = session;
this.args = args;
}
parse() {
const client = this.args.client;
const data_type = this.args.data_type
let processed_data;
switch (client) {
case 'YOUTUBE':
processed_data = ({
SEARCH: () => this.#processSearch(),
CHANNEL: () => this.#processChannel(),
PLAYLIST: () => this.#processPlaylist(),
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
LIBRARY: () => this.#processLibrary(), //WIP
TRENDING: () => this.#processTrending(),
HISTORY: () => this.#processHistory(),
COMMENTS: () => this.#processComments(),
VIDEO_INFO: () => this.#processVideoInfo(),
NOTIFICATIONS: () => this.#processNotifications(),
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
})[data_type]()
break;
case 'YTMUSIC':
processed_data = ({
SEARCH: () => this.#processMusicSearch(),
PLAYLIST: () => this.#processMusicPlaylist(),
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
})[data_type]();
break;
default:
throw new Utils.InnertubeError('Invalid client');
}
return processed_data;
}
#processSearch() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
const processed_data = {};
const parseItems = (contents) => {
const content = contents[0].itemSectionRenderer.contents;
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
processed_data.estimated_results = parseInt(this.data.estimatedResults);
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
processed_data.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await this.session.actions.search({ ctoken });
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
return parseItems(continuation_items);
};
return processed_data;
}
return parseItems(contents);
}
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
const processed_data = {
query: '',
corrected_query: '',
results: {}
};
processed_data.query = this.args.query;
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
contents.forEach((content) => {
const section = content?.musicShelfRenderer;
if (section) {
const section_title = section.title.runs[0].text;
const section_items = ({
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents),
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
}[section_title] || (() => {}))();
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
}
});
return processed_data;
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(JSON.parse(this.data.replace(')]}\'', '')));
}
#processMusicSearchSuggestions() {
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
}
#processPlaylist() {
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 || 'N/A',
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A',
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
}
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
const items = YTDataItems.PlaylistItem.parse(list.contents);
return {
...metadata,
items
}
}
#processMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details?.title?.runs[0].text,
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 = YTMusicDataItems.PlaylistItem.parse(playlist_content);
return {
...metadata,
items
}
}
/**
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
*/
#processVideoInfo() {
const playability_status = this.data.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
const details = this.data.videoDetails;
const microformat = this.data.microformat.playerMicroformatRenderer;
const streaming_data = this.data.streamingData;
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
const processed_data = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
// Extracts most of the metadata
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
(processed_data.metadata[key] = entry[1]);
} else {
processed_data[key] = entry[1];
}
});
// Extracts extra details
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' && (processed_data.metadata[key] = parseInt(entry[1])) ||
(processed_data.metadata[key] = entry[1]);
} else {
key == 'short_description' && (processed_data.description = entry[1]) ||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (processed_data.id = entry[1]) ||
(processed_data[key] = entry[1]);
}
});
// Data continuation is only required for getDetails()
if (this.data.continuation) {
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
const like_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
// These will always be false if logged out.
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
.state.buttonRenderer.icon.iconType || 'N/A';
// Simpler version of publish_date
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
// Only parse like count if it's enabled
if (processed_data.metadata.allow_ratings) {
processed_data.metadata.likes = {
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
};
}
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
}
streaming_data && streaming_data.adaptiveFormats &&
(processed_data.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, '')))]) ||
(processed_data.metadata.available_qualities = []);
return processed_data;
}
#processComments() {
if (!this.data.onResponseReceivedEndpoints)
throw new Utils.UnavailableContentError('Comments section not available', this.args);
const header = Utils.findNode(this.data, 'onResponseReceivedEndpoints', 'commentsHeaderRenderer', 5, false);
const comment_count = parseInt(header.commentsHeaderRenderer.countText.runs[0].text.replace(/,/g, ''));
const page_count = parseInt(comment_count / 20);
const parseComments = (data) => {
const items = Utils.findNode(data, 'onResponseReceivedEndpoints', 'commentRenderer', 4, false);
const response = {
page_count,
comment_count,
items: []
};
response.items = items.map((item) => {
const comment = YTDataItems.CommentThread.parseItem(item);
if (comment) {
comment.like = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.dislike = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.reply = (text) => this.session.actions.engage('comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.report = async () => {
const payload = Utils.findNode(item, 'commentThreadRenderer', 'params', 10, false);
const form = await this.session.actions.flag('flag/get_form', { params: payload.params });
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false);
const flag = await this.session.actions.flag('flag/flag', { action: action.flagAction });
return flag;
};
comment.getReplies = async () => {
if (comment.metadata.reply_count === 0) throw new Utils.InnertubeError('This comment has no replies', comment);
const payload = Proto.encodeCommentRepliesParams(this.args.video_id, comment.metadata.id);
const next = await this.session.actions.next({ ctoken: payload });
return parseComments(next.data);
};
comment.translate = async (target_language) => {
const response = await this.session.actions.engage('comment/perform_comment_action', {
text: comment.text,
comment_action: 'translate',
comment_id: comment.metadata.id,
video_id: this.args.video_id,
target_language
});
const translated_content = Utils.findNode(response.data, 'frameworkUpdates', 'content', 7, false);
return {
success: response.success,
status_code: response.status_code,
translated_content: translated_content.content
}
}
return comment;
}
}).filter((c) => c);
response.comment = (text) => this.session.actions.engage('comment/create_comment', { video_id: this.args.video_id, text });
response.getContinuation = async () => {
const continuation_item = items.find((item) => item.continuationItemRenderer);
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end');
const is_reply = !!continuation_item.continuationItemRenderer.button;
const payload = Utils.findNode(continuation_item, 'continuationItemRenderer', 'token', is_reply && 5 || 3);
const next = await this.session.actions.next({ ctoken: payload.token });
return parseComments(next.data);
};
return response;
};
return parseComments(this.data);
}
#processHomeFeed() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const parseItems = (contents) => {
const videos = YTDataItems.VideoItem.parse(contents);
const getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return { videos, getContinuation };
}
return parseItems(contents);
}
#processLibrary() { // TODO: Finish this
const profile_data = Utils.findNode(this.data, 'contents', 'profileColumnRenderer', 3);
const stats_data = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnStatsRenderer);
const stats_items = stats_data.profileColumnStatsRenderer.items;
const userinfo = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnUserInfoRenderer);
const stats = {};
stats_items.forEach((item) => {
const label = item.profileColumnStatsEntryRenderer.label.runs.map((run) => run.text).join('');
stats[label.toLowerCase()] = parseInt(item.profileColumnStatsEntryRenderer.value.simpleText);
});
const profile = {
name: userinfo.profileColumnUserInfoRenderer?.title?.simpleText,
thumbnails: userinfo.profileColumnUserInfoRenderer?.thumbnail.thumbnails,
stats
}
// const content = Utils.findNode(this.data, 'contents', 'content', 8, false);
// console.info(content[0].itemSectionRenderer.contents[0].shelfRenderer);
return {
profile
}
}
#processSubscriptionFeed() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
const subsfeed = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_contents = section.itemSectionRenderer.contents[0];
const section_title = section_contents.shelfRenderer.title.runs[0].text;
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
const items = YTDataItems.GridVideoItem.parse(section_items);
subsfeed.items.push({
date: section_title,
videos: items
});
});
subsfeed.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
subsfeed.items = [];
return parseItems(ccontents);
}
return subsfeed;
};
return parseItems(contents);
}
#processChannel() {
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
const metadata = this.data.metadata;
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
const home_shelves = [];
home_contents.forEach((content) => {
if (content.itemSectionRenderer) {
const contents = content.itemSectionRenderer.contents[0];
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
const shelf = {
title: contents.shelfRenderer.title.runs[0].text,
content: []
};
shelf.content = list.items.map((item) => {
if (item.gridVideoRenderer) {
return YTDataItems.GridVideoItem.parseItem(item);
} else if (item.gridPlaylistRenderer) {
return YTDataItems.GridPlaylistItem.parseItem(item);
}
});
home_shelves.push(shelf);
}
});
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
return {
...ch_info,
content: {
// Home page of the channel, always available in the first request.
home_page: home_shelves,
// TODO: Implement these (note: they require additional requests)
getVideos: () => {},
getPlaylists: () => {},
getCommunity: () => {},
getChannels: () => {},
getAbout: () => {}
}
}
}
#processNotifications() {
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications');
const parseItems = (items) => {
const parsed_items = YTDataItems.NotificationItem.parse(items);
const getContinuation = async () => {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
const response = await this.session.actions.notifications('get_notification_menu', { ctoken });
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
}
return { items: parsed_items, getContinuation };
}
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
}
#processTrending() {
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
const categories = {};
tabs.forEach((tab) => {
const tab_renderer = tab.tabRenderer;
const tab_content = tab_renderer?.content;
const category_title = tab_renderer.title.toLowerCase();
categories[category_title] = {};
if (tab_content) { // The “Now” category is always available
const contents = tab_content.sectionListRenderer.contents;
categories[category_title].content = contents.map((content) => {
const shelf = content.itemSectionRenderer.contents[0].shelfRenderer;
const parsed_shelf = YTDataItems.ShelfRenderer.parse(shelf);
return parsed_shelf;
});
} else { // The rest can only be fetched with additional calls
const params = tab_renderer.endpoint.browseEndpoint.params;
categories[category_title].getVideos = async () => {
const response = await this.session.actions.browse('FEtrending', { params });
const tabs = Utils.findNode(response, 'contents', 'tabRenderer', 4, false);
const tab = tabs.find((tab) => tab.tabRenderer.title === tab_renderer.title);
const contents = tab.tabRenderer.content.sectionListRenderer.contents;
const items = Utils.findNode(contents, 'itemSectionRenderer', 'items', 8, false);
return YTDataItems.VideoItem.parse(items);
};
}
});
return categories;
}
#processHistory() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const history = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
const contents = section.itemSectionRenderer.contents;
const section_items = YTDataItems.VideoItem.parse(contents);
history.items.push({
date: section_title,
videos: section_items
});
});
history.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await this.session.actions.browse(ctoken, { is_ctoken: true });
history.items = [];
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return history;
}
return parseItems(contents);
}
}
module.exports = Parser;

View File

@@ -0,0 +1,14 @@
'use strict';
const VideoResultItem = require('./search/VideoResultItem');
const SearchSuggestionItem = require('./search/SearchSuggestionItem');
const PlaylistItem = require('./others/PlaylistItem');
const NotificationItem = require('./others/NotificationItem');
const VideoItem = require('./others/VideoItem');
const GridVideoItem = require('./others/GridVideoItem');
const GridPlaylistItem = require('./others/GridPlaylistItem');
const ChannelMetadata = require('./others/ChannelMetadata');
const ShelfRenderer = require('./others/ShelfRenderer');
const CommentThread = require('./others/CommentThread');
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread };

View File

@@ -0,0 +1,20 @@
'use strict';
class ChannelMetadata {
static parse(data) {
return {
title: data.channelMetadataRenderer.title,
description: data.channelMetadataRenderer.description,
metadata: {
url: data.channelMetadataRenderer?.channelUrl,
rss_urls: data.channelMetadataRenderer?.rssUrl,
vanity_channel_url: data.channelMetadataRenderer?.vanityChannelUrl,
external_id: data.channelMetadataRenderer?.externalId,
is_family_safe: data.channelMetadataRenderer?.isFamilySafe,
keywords: data.channelMetadataRenderer?.keywords
}
}
}
}
module.exports = ChannelMetadata;

View File

@@ -0,0 +1,36 @@
'use strict';
const Constants = require('../../../utils/Constants');
class CommentThread {
static parseItem(item) {
if (item.commentThreadRenderer || item.commentRenderer) {
const comment = item?.commentThreadRenderer?.comment || item;
const like_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.likeButton;
const dislike_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
return {
text: comment.commentRenderer.contentText.runs.map((run) => run.text).join(''),
author: {
name: comment.commentRenderer.authorText.simpleText,
thumbnails: comment.commentRenderer.authorThumbnail.thumbnails,
channel_id: comment.commentRenderer.authorEndpoint.browseEndpoint.browseId,
channel_url: Constants.URLS.YT_BASE + comment.commentRenderer.authorEndpoint.browseEndpoint.canonicalBaseUrl
},
metadata: {
published: comment.commentRenderer.publishedTimeText.runs[0].text,
is_reply: !!item.commentRenderer,
is_liked: like_btn.toggleButtonRenderer.isToggled,
is_disliked: dislike_btn.toggleButtonRenderer.isToggled,
is_pinned: comment.commentRenderer.pinnedCommentBadge && true || false,
is_channel_owner: comment.commentRenderer.authorIsChannelOwner,
like_count: parseInt(like_btn?.toggleButtonRenderer?.accessibilityData?.accessibilityData.label.replace(/\D/g, '')),
reply_count: comment.commentRenderer.replyCount || 0,
id: comment.commentRenderer.commentId,
}
}
}
}
}
module.exports = CommentThread;

View File

@@ -0,0 +1,20 @@
'use strict';
class GridPlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
return {
id: item?.gridPlaylistRenderer.playlistId,
title: item?.gridPlaylistRenderer.title?.runs?.map((run) => run.text).join(''),
metadata: {
thumbnail: item?.gridPlaylistRenderer.thumbnail?.thumbnails?.slice(-1)[0] || {},
video_count: item?.gridPlaylistRenderer.videoCountShortText?.simpleText || 'N/A',
}
};
}
}
module.exports = GridPlaylistItem;

View File

@@ -0,0 +1,35 @@
'use strict';
const Constants = require('../../../utils/Constants');
class GridVideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
return {
id: item.gridVideoRenderer.videoId,
title: item?.gridVideoRenderer?.title?.runs?.map((run) => run.text).join(' '),
channel: {
id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: item?.gridVideoRenderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: item?.gridVideoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnail: item?.gridVideoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || [],
moving_thumbnail: item?.gridVideoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
published: item?.gridVideoRenderer?.publishedTimeText?.simpleText || 'N/A',
badges: item?.gridVideoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: item?.gridVideoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
};
}
}
module.exports = GridVideoItem;

View File

@@ -0,0 +1,25 @@
'use strict';
class NotificationItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
if (item.notificationRenderer) {
const notification = item.notificationRenderer;
return {
title: notification?.shortMessage?.simpleText,
sent_time: notification?.sentTimeText?.simpleText,
channel_name: notification?.contextualMenu?.menuRenderer?.items[1]?.menuServiceItemRenderer?.text?.runs[1]?.text || 'N/A',
channel_thumbnail: notification?.thumbnail?.thumbnails[0],
video_thumbnail: notification?.videoThumbnail?.thumbnails[0],
video_url: notification.navigationEndpoint.watchEndpoint && `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}` || 'N/A',
read: notification.read,
notification_id: notification.notificationId,
};
}
}
}
module.exports = NotificationItem;

View File

@@ -0,0 +1,26 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(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?.simpleText || '0'),
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
};
}
}
module.exports = PlaylistItem;

View File

@@ -0,0 +1,41 @@
'use strict';
const VideoItem = require('./VideoItem');
const GridVideoItem = require('./GridVideoItem');
class ShelfRenderer {
static parse(data) {
return {
title: this.getTitle(data.title),
videos: this.parseItems(data.content)
}
}
static getTitle(data) {
if ('runs' in (data || {})) {
return data.runs.map((run) => run.text).join('');
} else if ('simpleText' in (data || {})) {
return data.simpleText;
} else {
return 'Others';
}
}
static parseItems(data) {
let items;
if ('expandedShelfContentsRenderer' in data) {
items = data.expandedShelfContentsRenderer.items;
} else if ('horizontalListRenderer' in data) {
items = data.horizontalListRenderer.items;
}
const videos = ('gridVideoRenderer' in items[0])
&& GridVideoItem.parse(items)
|| VideoItem.parse(items);
return videos;
}
}
module.exports = ShelfRenderer;

View File

@@ -0,0 +1,46 @@
'use strict';
const Utils = require('../../../utils/Utils');
const Constants = require('../../../utils/Constants');
class VideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
item = (item.richItemRenderer && item.richItemRenderer.content.videoRenderer)
&& item.richItemRenderer.content
|| item;
if (item.videoRenderer) return {
id: item.videoRenderer.videoId,
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
channel: {
id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: item?.videoRenderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: item?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
published: item?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
duration: {
seconds: Utils.timeToSeconds(item?.videoRenderer?.lengthText?.simpleText || '0'),
simple_text: item?.videoRenderer?.lengthText?.simpleText || 'N/A',
accessibility_label: item?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
badges: item?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: item?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}
}
module.exports = VideoItem;

View File

@@ -0,0 +1,12 @@
'use strict';
class SearchSuggestionItem {
static parse(data) {
return {
query: data[0],
results: data[1].map((res) => res[0])
}
}
}
module.exports = SearchSuggestionItem;

View File

@@ -0,0 +1,43 @@
'use strict';
const Utils = require('../../../utils/Utils');
const Constants = require('../../../utils/Constants');
class VideoResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
const renderer = item.videoRenderer || item.compactVideoRenderer;
if (renderer) return {
id: renderer.videoId,
url: `https://youtu.be/${renderer.videoId}`,
title: renderer.title.runs[0].text,
description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
channel: {
id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: renderer?.ownerText?.runs[0]?.text,
url: `${Constants.URLS.YT_BASE}${renderer?.ownerText?.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: renderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnails: renderer?.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
simple_text: renderer?.lengthText?.simpleText || 'N/A',
accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
published: renderer?.publishedTimeText?.simpleText || 'N/A',
badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
}
};
}
}
module.exports = VideoResultItem;

View File

@@ -0,0 +1,12 @@
'use strict';
const SongResultItem = require('./search/SongResultItem');
const VideoResultItem = require('./search/VideoResultItem');
const AlbumResultItem = require('./search/AlbumResultItem');
const ArtistResultItem = require('./search/ArtistResultItem');
const PlaylistResultItem = require('./search/PlaylistResultItem');
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,28 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
}
static parseItem(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: {
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
},
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}
}
module.exports = PlaylistItem;

View File

@@ -0,0 +1,21 @@
'use strict';
class AlbumResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
return {
id: list_item.navigationEndpoint.browseEndpoint.browseId,
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,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = AlbumResultItem;

View File

@@ -0,0 +1,19 @@
'use strict';
class ArtistResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
return {
id: list_item.navigationEndpoint.browseEndpoint.browseId,
name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = ArtistResultItem;

View File

@@ -0,0 +1,22 @@
'use strict';
class MusicSearchSuggestionItem {
static parse(data) {
return {
query: this.parseItem(data[0]).runs[0].text.trim(),
results: data.map((item) => this.parseItem(item).runs.map((run) => run.text).join('').trim())
}
}
static parseItem(item) {
let suggestion;
item.historySuggestionRenderer &&
(suggestion = item.historySuggestionRenderer.suggestion) ||
(suggestion = item.searchSuggestionRenderer.suggestion);
return suggestion;
}
}
module.exports = MusicSearchSuggestionItem;

View File

@@ -0,0 +1,23 @@
'use strict';
class PlaylistResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(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 || '0',
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
};
}
}
module.exports = PlaylistResultItem;

View File

@@ -0,0 +1,22 @@
'use strict';
class SongResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(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
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = SongResultItem;

View File

@@ -0,0 +1,33 @@
'use strict';
const SongResultItem = require('./SongResultItem');
const VideoResultItem = require('./VideoResultItem');
const AlbumResultItem = require('./AlbumResultItem');
const ArtistResultItem = require('./ArtistResultItem');
const PlaylistResultItem = require('./PlaylistResultItem');
class TopResultItem {
static parse(data) {
return data.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
const type = runs[0].text.toLowerCase();
const parsed_item = ({
playlist: () => PlaylistResultItem.parseItem(item),
song: () => SongResultItem.parseItem(item),
video: () => VideoResultItem.parseItem(item),
artist: () => ArtistResultItem.parseItem(item),
album: () => AlbumResultItem.parseItem(item),
single: () => AlbumResultItem.parseItem(item)
}[type] || (() => {}))();
parsed_item && (parsed_item.type = type);
return parsed_item;
}).filter((item) => item);
}
}
module.exports = TopResultItem;

View File

@@ -0,0 +1,22 @@
'use strict';
class VideoResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(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
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = VideoResultItem;

287
lib/proto/index.js Normal file
View File

@@ -0,0 +1,287 @@
'use strict';
const messages = require('./messages');
class Proto {
/**
* Encodes visitor data.
*
* @param {string} id
* @param {number} timestamp
*
* @returns {string}
*/
static encodeVisitorData(id, timestamp) {
const buf = messages.VisitorData.encode({ id, timestamp });
return encodeURIComponent(Buffer.from(buf).toString('base64').replace(/\/|\+/g, '_'));
}
/**
* Encodes basic channel analytics parameters.
*
* @param {string} channel_id
* @returns {string}
*/
static encodeChannelAnalyticsParams(channel_id) {
const buf = messages.ChannelAnalytics.encode({ params: { channel_id } });
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filters.
*
* @param {object} filters
* @param {string} [filters.upload_date] - any | last_hour | today | this_week | this_month | this_year
* @param {string} [filters.type] - any | video | channel | playlist | movie
* @param {string} [filters.duration] - any | short | medium | long
* @param {string} [filters.sort_by] - relevance | rating | upload_date | view_count
* @todo implement remaining filters.
*
* @returns {string}
*/
static encodeSearchFilter(filters) {
const upload_dates = {
'any': null,
'last_hour': 1,
'today': 2,
'this_week': 3,
'this_month': 4,
'this_year': 5
};
const types = {
'any': null,
'video': 1,
'channel': 2,
'playlist': 3,
'movie': 4
};
const durations = {
'any': null,
'short': 1,
'medium': 2,
'long': 3
};
const orders = {
'relevance': null,
'rating': 1,
'upload_date': 2,
'view_count': 3
};
const data = {};
filters &&
(data.filters = {}) ||
(data.no_filter = 0);
if (filters) {
if (filters.upload_date) {
if (!['video', 'movie'].includes(filters.type))
throw new Error('Cannot use upload date filter with type ' + filters.type);
}
filters.upload_date &&
(data.filters.param_0 = upload_dates[filters.upload_date]);
filters.type &&
(data.filters.param_1 = types[filters.type]);
filters.duration &&
(data.filters.param_2 = durations[filters.duration]);
filters.sort_by &&
(filters.sort_by !== 'relevance') &&
(data.filter = orders[filters.sort_by]);
}
const buf = messages.SearchFilter.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes livechat message parameters.
*
* @param {string} channel_id
* @param {string} video_id
*
* @returns {string}
*/
static encodeMessageParams(channel_id, video_id) {
const buf = messages.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 section parameters.
*
* @param {string} video_id
* @param {object} options
* @param {string} options.type
* @param {string} options.sort_by
*
* @returns {string}
*/
static encodeCommentsSectionParams(video_id, options = {}) {
const sort_menu = { TOP_COMMENTS: 0, NEWEST_FIRST: 1 };
const buf = messages.GetCommentsSectionParams.encode({
ctx: { video_id },
unk_param: 6,
params: {
opts: {
video_id,
sort_by: sort_menu[options.sort_by || 'TOP_COMMENTS'],
type: options.type || 2
},
target: 'comments-section'
}
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes replies thread parameters.
*
* @param {string} video_id
* @param {string} comment_id
*
* @returns {string}
*/
static encodeCommentRepliesParams(video_id, comment_id) {
const buf = messages.GetCommentsSectionParams.encode({
ctx: { video_id },
unk_param: 6,
params: {
replies_opts: {
video_id, comment_id,
unkopts: { unk_param: 0 },
unk_param_1: 1, unk_param_2: 10,
channel_id: ' ' // Seems like this can be omitted
},
target: `comment-replies-item-${comment_id}`
}
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment parameters.
*
* @param {string} video_id
* @returns {string}
*/
static encodeCommentParams(video_id) {
const buf = messages.CreateCommentParams.encode({
video_id, params: { index: 0 },
number: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment reply parameters.
*
* @param {string} comment_id
* @param {string} video_id
*
* @return {string}
*/
static encodeCommentReplyParams(comment_id, video_id) {
const buf = messages.CreateCommentReplyParams.encode({
video_id, comment_id,
params: { unk_num: 0 },
unk_num: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment action parameters.
*
* @param {string} type
* @param {string} comment_id
* @param {string} video_id
* @param {string} [text]
* @param {string} [target_language]
*
* @returns {string}
*/
static encodeCommentActionParams(type, args = {}) {
const data = {};
data.type = type;
data.video_id = args.video_id || '';
data.comment_id = args.comment_id || '';
data.unk_num = 2;
if (args.hasOwnProperty('text')) {
args.comment_id && (delete data.unk_num);
data.translate_comment_params = {
params: {
comment: {
text: args.text
}
},
comment_id: args.comment_id || '',
target_language: args.target_language
}
}
const buf = messages.PeformCommentActionParams.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes notification preference parameters.
*
* @param {string} channel_id
* @param {number} index
*
* @returns {string}
*/
static encodeNotificationPref(channel_id, index) {
const buf = messages.NotificationPreferences.encode({
channel_id, pref_id: { index },
number_0: 0, number_1: 4
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes sound info parameters.
*
* @param {string} id
* @returns {string}
*/
static encodeSoundInfoParams(id) {
const data = {
sound: {
params: {
ids: {
id_1: id,
id_2: id,
id_3: id
}
}
}
}
const buf = messages.SoundInfoParams.encode(data);
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
}
module.exports = Proto;

2767
lib/proto/messages.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,67 @@
syntax = "proto2";
package proto;
package youtube;
message VisitorData {
string id = 1;
int32 timestamp = 5;
}
message ChannelAnalytics {
message Params {
string channel_id = 1001;
}
Params params = 32;
}
message InnertubePayload {
message Context {
message Client {
int32 unkparam = 16;
string client_version = 17;
string client_name = 18;
}
Client client = 1;
}
Context context = 1;
optional string target = 2;
message SoundSearchParams {
string target_id = 2;
string query = 3;
}
optional SoundSearchParams sound_search_params = 16;
}
message SoundInfoParams {
message Sound {
message Params {
message Ids {
string id_1 = 1;
string id_2 = 2;
string id_3 = 3;
}
Ids ids = 2;
}
Params params = 1;
}
Sound sound = 94;
}
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
optional int32 number_0 = 3;
optional int32 number_1 = 4;
}
message LiveMessageParams {
@@ -20,8 +73,51 @@ message LiveMessageParams {
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
optional int32 number_0 = 2;
optional int32 number_1 = 3;
}
message GetCommentsSectionParams {
message Context {
string video_id = 2;
}
Context ctx = 2;
int32 unk_param = 3;
message Params {
optional string unk_token = 1;
message Options {
string video_id = 4;
int32 sort_by = 6;
int32 type = 15;
}
message RepliesOptions {
string comment_id = 2;
message UnkOpts {
int32 unk_param = 1;
}
UnkOpts unkopts = 4;
optional string channel_id = 5;
string video_id = 6;
int32 unk_param_1 = 8;
int32 unk_param_2 = 9;
}
optional Options opts = 4;
optional RepliesOptions replies_opts = 3;
optional int32 page = 5;
string target = 8;
}
Params params = 6;
}
message CreateCommentParams {
@@ -31,4 +127,53 @@ message CreateCommentParams {
}
Params params = 5;
int32 number = 10;
}
message CreateCommentReplyParams {
string video_id = 2;
string comment_id = 4;
message UnknownParams {
int32 unk_num = 1;
}
UnknownParams params = 5;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
int32 type = 1;
string comment_id = 3;
string video_id = 5;
optional int32 unk_num = 2;
optional string channel_id = 23;
message TranslateCommentParams {
message Params {
message Comment {
string text = 1;
}
Comment comment = 1;
}
Params params = 3;
string comment_id = 2;
string target_language = 4;
}
optional TranslateCommentParams translate_comment_params = 31;
}
message SearchFilter {
optional int32 filter = 1; // almost always sort_by
optional int32 no_filter = 19;
message Filters {
optional int32 param_0 = 1;
optional int32 param_1 = 2;
optional int32 param_2 = 3;
}
optional Filters filters = 2;
}

108
lib/utils/Constants.js Normal file
View File

@@ -0,0 +1,108 @@
'use strict';
module.exports = {
URLS: {
YT_BASE: 'https://www.youtube.com',
YT_BASE_API: 'https://www.youtube.com/youtubei/',
YT_STUDIO_BASE_API: 'https://studio.youtube.com/youtubei/',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
YT_MUSIC: 'https://music.youtube.com',
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
},
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'
}
},
REGEX: {
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:"(?<id>.+?)",.+?:"(?<secret>.+?)"},/
}
},
CLIENTS: {
WEB: {
NAME: 'WEB'
},
YTMUSIC: {
NAME: 'WEB_REMIX',
VERSION: '1.20211213.00.00'
},
ANDROID: {
NAME: 'ANDROID',
VERSION: '17.17.32'
}
},
STREAM_HEADERS: {
'accept': '*/*',
'connection': 'keep-alive',
'origin': 'https://www.youtube.com',
'referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_HEADERS_BASE: {
'accept': '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
},
METADATA_KEYS: [
'embed', 'view_count', 'average_rating', 'allow_ratings',
'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', '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('')
},
SIG_REGEX: {
ACTIONS: /;.{2}\.(?<name>.{2})\(.*?,(?<param>.*?)\)/g,
FUNCTIONS: /(?<name>.{2}):function\(.*?\){(.*?)}/g
},
NTOKEN_REGEX: {
CALLS: /c\[(.*?)\]\((.+?)\)/g,
PLACEHOLDERS: /c\[(.*?)\]=c/g,
FUNCTIONS: /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'
}
};

122
lib/utils/Request.js Normal file
View File

@@ -0,0 +1,122 @@
'use strict';
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/** @namespace */
class Request {
/**
* @param {Innertube} session
* @constructor
*/
constructor(session) {
this.session = session;
this.instance = Axios.create({
baseURL: Constants.URLS.YT_BASE_API + session.version,
headers: Constants.INNERTUBE_HEADERS_BASE,
params: { key: session.key, prettyPrint: false },
validateStatus: () => true,
timeout: 15000
});
this.#setupInterceptor();
return this.instance;
}
#setupInterceptor() {
this.instance.interceptors.request.use((config) => {
const is_json_payload = typeof config.data == 'object';
config.headers['user-agent'] = Utils.getRandomUserAgent('desktop').userAgent;
config.headers['accept-language'] = `en-${this.session.config.gl || 'US'}`;
config.headers['x-goog-visitor-id'] = this.session.context.client.visitorData || '';
config.headers['x-youtube-client-version'] = this.session.context.client.clientVersion;
if (is_json_payload) {
config.data = {
context: JSON.parse(JSON.stringify(this.session.context)), // deep copies the context object
...config.data
};
this.#adjustContext(config.data.context, config.data.client);
config.headers['x-youtube-client-version'] = config.data.context.client.clientVersion;
config.headers['x-origin'] = config.data.context.client.originalUrl;
config.headers['origin'] = config.data.context.client.originalUrl;
config.data.client == 'YTMUSIC' &&
(config.baseURL = Constants.URLS.YT_MUSIC_BASE_API + this.session.version);
delete config.data.client;
}
if (this.session.logged_in) {
const cookie = this.session.config.cookie;
const token = cookie &&
this.session.auth_apisid ||
this.session.access_token;
config.headers.cookie = cookie || '';
config.headers.authorization = cookie && token || `Bearer ${token}`;
!cookie && (delete config.params.key);
}
this.session.config.debug &&
console.info('\n', '[' + config.method.toUpperCase() + ']', '>', config.baseURL + config.url, '\n', config?.data, '\n');
return config;
}, (error) => {
throw new Utils.InnertubeError(error.message, error);
});
/**
* Standardizes the API response and catches all errors.
*/
this.instance.interceptors.response.use((res) => {
const response = {
success: res.status === 200,
status_code: res.status,
data: res.data
};
if (res.status !== 200)
throw new Utils.InnertubeError(`Request to ${res.config.url} failed with status code ${res.status} ${res.statusText}`, response);
return response;
});
this.instance.interceptors.response.use(undefined, (error) => {
if (error.info) return Promise.reject(error);
throw new Utils.InnertubeError('Could not complete this operation', error.message);
});
}
/**
* Adjusts the context according to the given client.
* @todo refactor this?
* @returns
*/
#adjustContext(ctx, client) {
switch (client) {
case 'YTMUSIC':
ctx.client.originalUrl = Constants.URLS.YT_MUSIC;
ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION;
ctx.client.clientName = Constants.CLIENTS.YTMUSIC.NAME;
break;
case 'ANDROID':
ctx.client.originalUrl = Constants.URLS.YT_BASE;
ctx.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION;
ctx.client.clientName = Constants.CLIENTS.ANDROID.NAME;
break;
default:
break;
}
}
}
module.exports = Request;

180
lib/utils/Utils.js Normal file
View File

@@ -0,0 +1,180 @@
'use strict';
const Crypto = require('crypto');
const UserAgent = require('user-agents');
const Flatten = require('flat');
/** @namespace */
class InnertubeError extends Error {
constructor (message, info) {
super(message);
info && (this.info = info);
this.date = new Date();
this.version = require('../../package.json').version;
}
}
class ParsingError extends InnertubeError {}
class DownloadError extends InnertubeError {}
class MissingParamError extends InnertubeError {}
class UnavailableContentError extends InnertubeError {}
class NoStreamingDataError extends InnertubeError {}
/**
* Utility to help access deep properties of an object.
*
* @param {object} obj - the object.
* @param {string} key - key of the property being accessed.
* @param {string} target - anything that might be inside of the property.
* @param {number} depth - maximum number of nested objects to flatten.
* @param {boolean} safe - if set to true arrays will be preserved.
*
* @returns {object|Array.<any>}
*/
function findNode(obj, key, target, depth, safe = true) {
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 });
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj, null, 4).slice(0, 300)}..` });
return flat_obj[result];
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
}
/**
* Finds a string between two delimiters.
*
* @param {string} data - the data.
* @param {string} start_string - start string.
* @param {string} end_string - end string.
*
* @returns {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;
}
/**
* 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(' ');
}
/**
* Generates a random string with a given length.
*
* @param {number} length
* @returns {string}
*/
function generateRandomString(length) {
const result = [];
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
for (let i = 0; i < length; i++) {
result.push(alphabet.charAt(Math.floor(Math.random() * alphabet.length)));
}
return result.join('');
}
/**
* 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()}`);
}
/**
* Checks if a given client is valid.
*
* @param {string} client
* @returns {boolean}
*/
function isValidClient(client) {
return [ 'YOUTUBE', 'YTMUSIC' ].includes(client);
}
/**
* Throws an error if given parameters are undefined.
*
* @param {object} params
* @returns
*/
function throwIfMissing(params) {
for (const [key, value] of Object.entries(params)) {
if (!value)
throw new MissingParamError(`${key} is missing`);
}
}
/**
* 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])}');
}
const errors = { InnertubeError, UnavailableContentError, ParsingError, DownloadError, MissingParamError, NoStreamingDataError };
const functions = { findNode, getRandomUserAgent, generateSidAuth, generateRandomString, getStringBetweenStrings, camelToSnake, isValidClient, throwIfMissing, timeToSeconds, refineNTokenData };
module.exports = { ...functions, ...errors };

7071
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,67 @@
{
"name": "youtubei.js",
"version": "1.2.3",
"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!",
"version": "1.4.3",
"description": "A full-featured wrapper around YouTube's private API. Allows you to retrieve info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "LuanRT",
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": "https://ko-fi.com/luanrt",
"license": "MIT",
"engines": {
"node": ">=14"
},
"scripts": {
"test": "jest",
"lint": "eslint ./",
"lint:fix": "eslint --fix ./",
"build:types": "npx tsc"
},
"types": "./typings/index.d.ts",
"directories": {
"example": "examples",
"lib": "lib"
"test": "./test",
"typings": "./typings",
"examples": "./examples",
"lib": "./lib"
},
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"time-to-seconds": "^1.1.5",
"flat": "^5.0.2",
"protocol-buffers-encodings": "^1.1.1",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/node": "^17.0.31",
"typescript": "^4.6.4",
"eslint": "^8.15.0",
"jest": "^28.1.0"
},
"repository": {
"type": "git",
"url": "git+https//github.com/LuanRT/YouTube.js.git"
"url": "git+https://github.com/LuanRT/YouTube.js.git"
},
"keywords": [
"youtube",
"youtube-dl",
"innertube",
"innertubeapi",
"livechat",
"api",
"search",
"like",
"dislike",
"comment",
"automation",
"downloader",
"comments-section",
"youtube-downloader"
],
"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",
"innertube",
"innertubeapi",
"unofficial",
"downloader",
"livechat",
"ytmusic",
"dislike",
"search",
"comment",
"music",
"like",
"api"
]
}

36
test/constants.js Normal file
View File

@@ -0,0 +1,36 @@
module.exports = {
VIDEOS: [
{
ID: 'bUHZ2k9DYHY',
QUERY: 'Space DOES NOT expand everywhere'
},
{
ID: 'WSeNSzJ2-Jw',
QUERY: 'Scary Monsters and Nice Sprites Official Audio'
}
],
DECIPHERS: {
SIG: {
ORIGINAL_URL: 's=t%3DQ%3DAv2TLJ2sbQFV5msp4j7v71gS1rsXNd6QH2V1KpxGlaOD%3DIC46mVzTVTW_2zttE32HKH7XO1jkyfOJs58avqMLKdvRdgIQRw8JQ0qOA&sp=sig&url=https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback%3Fexpire%3D1635863482%26ei%3DWveAYdqsB6KPobIPjtWwYA%26ip%3D128.201.98.50%26id%3Do-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DG3%26mm%3D31%252C29%26mn%3Dsn-hxtxgcg-8qjl%252Csn-gpv7dned%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D1%26pl%3D24%26initcwndbps%3D397500%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dv9CYauI2ycUgrV6wOERCNxsG%26gir%3Dyes%26clen%3D7275579%26ratebypass%3Dyes%26dur%3D218.290%26lmt%3D1540416860737282%26mt%3D1635841731%26fvip%3D4%26fexp%3D24001373%252C24007246%26c%3DWEB%26txp%3D5531432%26n%3DD8yGa-DC5m2Dwv--%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX',
DECIPHERED_URL: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=D8yGa-DC5m2Dwv--&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D',
ALGORITHM: `fB={RP:function(a,b){a.splice(0,b)},
Td:function(a){a.reverse()},
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`
},
N: {
ORIGINAL_TOKEN: 'PqjqqJjdB9K821VIisj',
DECIPHERED_TOKEN: 'AxwyS-osUl1WhMUd1',
ALGORITHM: `var b=a.split(""),c=[-470482026,-691770757,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
b,258876269,-1426380890,318754300,-68090711,-2064438462,-1886316521,1913911047,1635047330,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
-1815897225,1940621629,-714586149,-1723898467,null,778601498,2145333248,1245726977,1952270083,268207944,244274044,null,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(0,1,d.splice(e,1,d[0])[0])},
null,-762271981,604636391,1087224318,-931565987,-338396815,function(d,e){for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())},
2126741474,function(d){for(var e=d.length;e;)d.push(d.splice(--e,1)[0])},
-1874551858,-1238260579,498106911,1913911047,-1951114300,-504396507,b,344510945,905306344,b,function(d,e){e=(e%d.length+d.length)%d.length;var f=d[0];d[0]=d[e];d[e]=f},
909033134,1027812119,1686673079,function(d,e){d.push(e)},
-1902376100,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(e,1)},
"push",function(d,e){for(var f=64,h=[];++f-h.length-32;)switch(f){case 58:f=96;continue;case 91:f=44;break;case 65:f=47;continue;case 46:f=153;case 123:f-=58;default:h.push(String.fromCharCode(f))}d.forEach(function(l,m,n){this.push(n[m]=h[(h.indexOf(l)-h.indexOf(this[m])+m-32+f--)%h.length])},e.split(""))}];
c[17]=c;c[24]=c;c[26]=c;try{c[45](c[17],c[38]),c[12](c[44],c[29]),c[45](c[26],c[0]),c[51](c[41],c[13]),c[12](c[41],c[27]),c[12](c[26],c[11]),c[39](c[17],c[49]),c[9](c[38],c[47]),c[26](c[40],c[0]),c[7](c[8],c[44]),c[14](c[54],c[0]),c[18](c[3],c[25]),c[7](c[33],c[36]),c[15](c[19],c[14]),c[7](c[19],c[9]),c[7](c[6],c[12]),c[41](c[33],c[35]),c[7](c[40],c[5]),c[50](c[42]),c[13](c[14],c[17]),c[6](c[35],c[51]),c[26](c[48],c[50]),c[26](c[35],c[0]),c[6](c[21],c[46]),c[15](c[21],c[42]),c[1](c[2],c[43]),c[15](c[2],
c[31]),c[1](c[21],c[25]),c[22](c[30],c[17]),c[15](c[44],c[46]),c[22](c[44],c[11]),c[22](c[23],c[38]),c[1](c[23],c[14]),c[35](c[23],c[44]),c[11](c[53],c[20]),c[9](c[51]),c[31](c[51],c[28]),c[18](c[51],c[35]),c[46](c[53],c[6]),c[52](c[51],c[49]),c[11](c[53],c[15])}catch(d){return"enhanced_except_75MBkOz-_w8_"+a} return b.join("");`
}
}
}

111
test/main.test.js Normal file
View File

@@ -0,0 +1,111 @@
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/deciphers/NToken');
const Signature = require('../lib/deciphers/Signature');
const Constants = require('./constants');
describe('YouTube.js Tests', () => {
beforeAll(async () => {
this.session = await new Innertube();
});
describe('Search', () => {
it('Should search on YouTube', async () => {
const search = await this.session.search(Constants.VIDEOS[0].QUERY, { client: 'YOUTUBE' });
expect(search.videos.length).toBeLessThanOrEqual(20);
});
it('Should search on YouTube Music', async () => {
const search = await this.session.search(Constants.VIDEOS[1].QUERY, { client: 'YTMUSIC' });
expect(search.results.songs.length).toBeLessThanOrEqual(3);
});
it('Should retrieve YouTube search suggestions', async () => {
const suggestions = await this.session.getSearchSuggestions(Constants.VIDEOS[0].QUERY, { client: 'YOUTUBE' });
expect(suggestions.results.length).toBeLessThanOrEqual(10);
});
it('Should retrieve YouTube Music search suggestions', async () => {
const suggestions = await this.session.getSearchSuggestions(Constants.VIDEOS[1].QUERY, { client: 'YTMUSIC' });
expect(suggestions.results.length).toBeLessThanOrEqual(10);
});
});
describe('Comments', () => {
it('Should retrieve comments', async () => {
this.comments = await this.session.getComments(Constants.VIDEOS[1].ID);
expect(this.comments.items.length).toBeLessThanOrEqual(20);
});
it('Should retrieve comment thread continuation', async () => {
const next = await this.comments.getContinuation();
expect(next.items.length).toBeLessThanOrEqual(20);
});
it('Should retrieve comment replies', async () => {
const top_comment = this.comments.items[0];
const replies = await top_comment.getReplies();
expect(replies.items.length).toBeLessThanOrEqual(10);
});
});
describe('Playlists', () => {
it('Should retrieve playlist with YouTube', async () => {
const playlist = await this.session.getPlaylist('PLLw0AzOz95FU7w2juhPECP9NyGhbZmz_t', { client: 'YOUTUBE' });
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
it('Should retrieve playlist with YouTube Music', async () => {
const playlist = await this.session.getPlaylist('PLLw0AzOz95FU7w2juhPECP9NyGhbZmz_t', { client: 'YTMUSIC' });
expect(playlist.items.length).toBeLessThanOrEqual(100);
});
});
describe('General', () => {
it('Should retrieve home feed', async () => {
const homefeed = await this.session.getHomeFeed();
expect(homefeed.videos.length).toBeLessThanOrEqual(30);
});
it('Should retrieve trending content', async () => {
const trending = await this.session.getTrending();
expect(trending.now.content[0].videos.length).toBeLessThanOrEqual(100);
});
it('Should retrieve video info', async () => {
const details = await this.session.getDetails(Constants.VIDEOS[0].ID);
expect(details.id).toBe(Constants.VIDEOS[0].ID);
});
it('Should download video', async () => {
const result = await download(Constants.VIDEOS[1].ID, this.session);
expect(result).toBeTruthy();
});
});
describe('Deciphers', () => {
it('Should decipher signature', () => {
const result = new Signature(Constants.DECIPHERS.SIG.ORIGINAL_URL, Constants.DECIPHERS.SIG.ALGORITHM).decipher();
expect(result).toEqual(Constants.DECIPHERS.SIG.DECIPHERED_URL);
});
it('Should decipher ntoken', () => {
const result = new NToken(Constants.DECIPHERS.N.ALGORITHM, Constants.DECIPHERS.N.ORIGINAL_TOKEN).transform();
expect(result).toEqual(Constants.DECIPHERS.N.DECIPHERED_TOKEN);
});
});
});
function download(id, session) {
let got_video_info = false;
return new Promise((resolve, reject) => {
const stream = session.download(id, { type: 'videoandaudio' });
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => resolve(Fs.existsSync(`./${id}.mp4`) && got_video_info));
stream.on('info', () => got_video_info = true);
stream.on('error', () => resolve(false));
});
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"allowJs": true,
"outDir": "./typings",
"lib": ["ESNext"],
"target": "ESNext",
"moduleResolution": "node"
},
"include": [
"./lib/**/*.js",
"./index.js"
],
"exclude": [
"node_modules",
"**/*.d.ts"
]
}

2
typings/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _exports: typeof import("./lib/Innertube");
export = _exports;

330
typings/lib/Innertube.d.ts vendored Normal file
View File

@@ -0,0 +1,330 @@
export = Innertube;
/**
* Innertube instance.
* @namespace
*/
declare class Innertube {
/**
* @example
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
*
* @param {object} [config]
* @param {string} [config.gl]
* @param {string} [config.cookie]
* @param {boolean} [config.debug]
*
* @returns {Innertube}
* @constructor
*/
constructor(config?: {
gl?: string;
cookie?: string;
debug?: boolean;
});
config: {
gl?: string;
cookie?: string;
debug?: boolean;
};
key: any;
version: any;
context: any;
logged_in: boolean;
player_url: any;
sts: any;
/**
* @fires Innertube#auth - fired when signing in to an account.
* @fires Innertube#update-credentials - fired when the access token is no longer valid.
* @type {EventEmitter}
*/
ev: EventEmitter;
oauth: OAuth;
auth_apisid: any;
request: Request;
actions: Actions;
account: AccountManager;
playlist: PlaylistManager;
interact: InteractionManager;
/**
* 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?: {
access_token: string;
refresh_token: string;
expires: Date;
}): Promise<void>;
access_token: string;
refresh_token: string;
/**
* Signs out of an account.
* @returns {Promise.<{ success: boolean; status_code: number }>}
*/
signOut(): Promise<{
success: boolean;
status_code: number;
}>;
/**
* Searches a given query.
*
* @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 {object} [options.filters] - search filters.
* @param {string} [options.filters.upload_date] - filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
* @param {string} [options.filters.type] - filter results by type, can be: any | video | channel | playlist | movie
* @param {string} [options.filters.duration] - filter videos by duration, can be: any | short | medium | long
* @param {string} [options.filters.sort_by] - filter video results by order, can be: relevance | rating | upload_date | view_count
*
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: object[] } |
* { results: { songs: object[]; videos: object[]; albums: object[]; community_playlists: object[] } }>}
*/
search(query: string, options?: {
client?: string;
filters?: {
upload_date?: string;
type?: string;
duration?: string;
sort_by?: string;
};
}): Promise<{
query: string;
corrected_query: string;
estimated_results: number;
videos: object[];
} | {
results: {
songs: object[];
videos: object[];
albums: object[];
community_playlists: object[];
};
}>;
/**
* Retrieves search suggestions for a given query.
*
* @param {string} query - the search query.
* @param {object} [options] - search options.
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
*
* @returns {Promise.<{ query: string; results: string[] }>}
*/
getSearchSuggestions(query: string, options?: {
client?: string;
}): Promise<{
query: string;
results: string[];
}>;
/**
* Retrieves video info.
* @param {string} video_id - the video id.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
*/
getDetails(video_id: string): Promise<{
title: string;
description: string;
thumbnail: [];
metadata: object;
}>;
/**
* Retrieves comments for a given video.
*
* @param {string} video_id - the video id.
* @param {string} [sort_by] - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: object[]; }>}
*/
getComments(video_id: string, sort_by?: string): Promise<{
page_count: number;
comment_count: number;
items: object[];
}>;
/**
* Retrieves contents for a given channel. (WIP)
* @param {string} id - channel id
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
getChannel(id: string): Promise<{
title: string;
description: string;
metadata: object;
content: object;
}>;
/**
* Retrieves watch history.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
getHistory(): Promise<{
items: {
date: string;
videos: object[];
}[];
}>;
/**
* Retrieves home feed (aka recommendations).
* @returns {Promise.<{ videos: { id: string; title: string; description: string; channel: string; metadata: object }[] }>}
*/
getHomeFeed(): Promise<{
videos: {
id: string;
title: string;
description: string;
channel: string;
metadata: object;
}[];
}>;
/**
* Retrieves trending content.
* @returns {Promise.<{ now: { content: { title: string; videos: object[]; }[] };
* music: { getVideos: Promise.<Array.<object>>; }; gaming: { getVideos: Promise.<Array.<object>>; };
* movies: { getVideos: Promise.<Array.<object>>; } }>}
*/
getTrending(): Promise<{
now: {
content: {
title: string;
videos: object[];
}[];
};
music: {
getVideos: Promise<Array<object>>;
};
gaming: {
getVideos: Promise<Array<object>>;
};
movies: {
getVideos: Promise<Array<object>>;
};
}>;
/**
* @todo finish this
* WIP
*/
getLibrary(): Promise<any>;
/**
* Retrieves subscriptions feed.
* @returns {Promise.<{ items: { date: string; videos: object[] }[] }>}
*/
getSubscriptionsFeed(): Promise<{
items: {
date: string;
videos: object[];
}[];
}>;
/**
* Retrieves notifications.
* @returns {Promise.<{ items: { title: string; sent_time: string; channel_name: string; channel_thumbnail: object; video_thumbnail: object; video_url: string; read: boolean; notification_id: string }[] }>}
*/
getNotifications(): Promise<{
items: {
title: string;
sent_time: string;
channel_name: string;
channel_thumbnail: object;
video_thumbnail: object;
video_url: string;
read: boolean;
notification_id: string;
}[];
}>;
/**
* Retrieves unseen notifications count.
* @returns {Promise.<number>}
*/
getUnseenNotificationsCount(): Promise<number>;
/**
* Retrieves lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>}
*/
getLyrics(video_id: string): Promise<string>;
/**
* Retrieves the contents of a given playlist.
*
* @param {string} playlist_id - the id of the playlist.
* @param {object} options - `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: [] }>}
*/
getPlaylist(playlist_id: string, options?: {
client: string;
}): 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: [];
}>;
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} video_id - video id
* @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: object; formats: object[] }>}
*/
getStreamingData(video_id: string, options?: {
quality: string;
type: string;
format: string;
}): Promise<{
selected_format: object;
formats: object[];
}>;
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} video_id - video id
* @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
* @param {object} [options.range] - download range, indicates which bytes should be downloaded.
* @param {number} options.range.start - the beginning of the range.
* @param {number} options.range.end - the end of the range.
*
* @return {Stream.PassThrough}
*/
download(video_id: string, options?: {
quality?: string;
type?: string;
format?: string;
range?: {
start: number;
end: number;
};
}): Stream.PassThrough;
#private;
}
import EventEmitter = require("events");
import OAuth = require("./core/OAuth");
import Request = require("./utils/Request");
import Actions = require("./core/Actions");
import AccountManager = require("./core/AccountManager");
import PlaylistManager = require("./core/PlaylistManager");
import InteractionManager = require("./core/InteractionManager");
import Stream = require("stream");

192
typings/lib/core/AccountManager.d.ts vendored Normal file
View File

@@ -0,0 +1,192 @@
export = AccountManager;
/** @namespace */
declare class AccountManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/** @namespace */
channel: {
/**
* Edits channel name.
*
* @param {string} new_name
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editName: (new_name: string) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Edits channel description.
*
* @param {string} new_description
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editDescription: (new_description: string) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Retrieves basic channel analytics.
* @borrows AccountManager#getAnalytics as getBasicAnalytics
*/
getBasicAnalytics: () => Promise<{
metrics: {
title: string;
subtitle: string;
metric_value: string;
comparison_indicator: object;
series_configuration: object;
}[];
top_content: {
views: string;
published: string;
thumbnails: object[];
duration: string;
is_short: boolean;
}[];
}>;
};
/** @namespace */
settings: {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptions: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Recommended content notifications.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setRecommendedVideos: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify about activity on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setChannelActivity: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify about replies to your comments.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setCommentReplies: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify when others mention your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setMentions: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Notify when others share your content on their channels.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSharedContent: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
};
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptionsPrivate: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSavedPlaylistsPrivate: (option: boolean) => Promise<{
success: boolean;
status_code: number;
data: object;
}>;
};
};
/**
* Retrieves channel info.
* @returns {Promise.<{ name: string; email: string; channel_id: string; subscriber_count: string; photo: object[]; }>}
*/
getInfo(): Promise<{
name: string;
email: string;
channel_id: string;
subscriber_count: string;
photo: object[];
}>;
/**
* Retrieves time watched statistics.
* @returns {Promise.<[{ title: string; time: string; }]>}
*/
getTimeWatched(): Promise<[{
title: string;
time: string;
}]>;
/**
* Retrieves basic channel analytics.
*
* @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string;
* comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string;
* published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>}
*/
getAnalytics(): Promise<{
metrics: {
title: string;
subtitle: string;
metric_value: string;
comparison_indicator: object;
series_configuration: object;
}[];
top_content: {
views: string;
published: string;
thumbnails: object[];
duration: string;
is_short: boolean;
}[];
}>;
#private;
}

331
typings/lib/core/Actions.d.ts vendored Normal file
View File

@@ -0,0 +1,331 @@
export = Actions;
/** namespace **/
declare class Actions {
/**
* @param {Innertube} session
* @constructor
*/
constructor(session: Innertube);
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param {string} id - browseId or a continuation token
* @param {object} args - additional arguments
* @param {string} [args.params]
* @param {boolean} [args.is_ytm]
* @param {boolean} [args.is_ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
browse(id: string, args?: {
params?: string;
is_ytm?: boolean;
is_ctoken?: boolean;
client?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*
* @param {string} action
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.comment_id]
* @param {string} [args.comment_action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
engage(action: string, args?: {
video_id?: string;
channel_id?: string;
comment_id?: string;
comment_action?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints related to account management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_value]
* @param {string} [args.setting_item_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>}
*/
account(action: string, args?: {
new_value?: string;
setting_item_id?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used for search.
*
* @param {object} args
* @param {string} [args.query]
* @param {object} [args.options]
* @param {string} [args.options.period]
* @param {string} [args.options.duration]
* @param {string} [args.options.order]
* @param {string} [args.client]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
search(args?: {
query?: string;
options?: {
period?: string;
duration?: string;
order?: string;
};
client?: string;
ctoken?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used fo Shorts' sound search.
*
* @param {object} args
* @param {string} args.query
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
searchSound(args?: {
query: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Channel management endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.new_name]
* @param {string} [args.new_description]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
channel(action: string, args?: {
new_name?: string;
new_description?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used for playlist management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.title]
* @param {string} [args.ids]
* @param {string} [args.playlist_id]
* @param {string} [args.action]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
playlist(action: string, args?: {
title?: string;
ids?: string;
playlist_id?: string;
action?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used for notifications management.
*
* @param {string} action
* @param {object} args
* @param {string} [args.pref]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
notifications(action: string, args?: {
pref?: string;
channel_id?: string;
ctoken?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers livechat endpoints.
*
* @param {string} action
* @param {object} args
* @param {string} [args.text]
* @param {string} [args.video_id]
* @param {string} [args.channel_id]
* @param {string} [args.ctoken]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
livechat(action: string, args?: {
text?: string;
video_id?: string;
channel_id?: string;
ctoken?: string;
params?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used to retrieve video thumbnails.
*
* @param {object} args
* @param {string} args.video_id
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
thumbnails(args?: {
video_id: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*
* @param {string} action
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
geo(action: string, args?: {
input: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers endpoints used to report content.
*
* @param {string} action
* @param {object} args
* @param {object} [args.action]
* @param {string} [args.params]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
flag(action: string, args: {
action?: object;
params?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers specific YouTube Music endpoints.
*
* @param {string} action
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
music(action: string, args: any): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Mostly used for pagination and specific operations.
*
* @param {object} args
* @param {string} [args.video_id]
* @param {string} [args.ctoken]
* @param {string} [args.client]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
next(args?: {
video_id?: string;
ctoken?: string;
client?: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Used to retrieve video info.
*
* @param {string} id
* @param {string} [cpn]
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getVideoInfo(id: string, cpn?: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Covers search suggestion endpoints.
*
* @param {string} client
* @param {string} input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getSearchSuggestions(client: string, query: any): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Endpoint used to retrieve user mention suggestions.
*
* @param {object} args
* @param {string} args.input
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
getUserMentionSuggestions(args?: {
input: string;
}): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
#private;
}

107
typings/lib/core/InteractionManager.d.ts vendored Normal file
View File

@@ -0,0 +1,107 @@
export = InteractionManager;
/** @namespace */
declare class InteractionManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/**
* Likes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
like(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Dislikes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
dislike(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Removes a like/dislike.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
removeLike(video_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Subscribes to a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
subscribe(channel_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Unsubscribes from a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
unsubscribe(channel_id: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
comment(video_id: string, text: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
/**
* Translates a given text using YouTube's comment translate feature.
*
* @param {string} text
* @param {string} target_language - an ISO language code
* @param {object} [args] - optional arguments
* @param {string} [args.video_id]
* @param {string} [args.comment_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; translated_content: string; data: object; }>}
*/
translate(text: string, target_language: string, args?: {
video_id?: string;
comment_id?: string;
}): Promise<{
success: boolean;
status_code: number;
translated_content: string;
data: object;
}>;
/**
* 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: number; data: object; }>}
*/
setNotificationPreferences(channel_id: string, type: string): Promise<{
success: boolean;
status_code: number;
data: object;
}>;
#private;
}

23
typings/lib/core/Livechat.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export = Livechat;
declare class Livechat extends EventEmitter {
constructor(session: any, token: any, channel_id: any, video_id: any);
ctoken: any;
session: any;
video_id: any;
channel_id: any;
message_queue: any[];
id_cache: any[];
poll_intervals_ms: number;
running: boolean;
metadata_ctoken: any;
livechat_poller: NodeJS.Timeout;
sendMessage(text: any): Promise<any>;
/**
* Blocks a user.
* @todo Implement this method
*/
blockUser(): Promise<void>;
stop(): void;
#private;
}
import EventEmitter = require("events");

47
typings/lib/core/OAuth.d.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
export = OAuth;
/** @namespace */
declare class OAuth {
/**
* @param {EventEmitter} ev
* @constructor
*/
constructor(ev: EventEmitter);
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
init(auth_info: any): Promise<void>;
client_id: string;
client_secret: string;
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
checkTokenValidity(): Promise<void>;
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
revokeAccessToken(): Promise<void>;
/**
* Returns the access token.
* @returns {string}
*/
getAccessToken(): string;
/**
* Returns the refresh token.
* @returns {string}
*/
getRefreshToken(): string;
/**
* Checks if the auth info format is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo(): boolean;
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
shouldRefreshToken(): boolean;
#private;
}

37
typings/lib/core/Player.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
export = Player;
/** @namespace */
declare class Player {
/**
* Represents the YouTube Web player script.
* @param {string} id - the id of the player.
* @constructor
*/
constructor(id: string);
init(): Promise<Player>;
/**
* Returns the current player's url.
* @readonly
* @returns {string}
*/
readonly get url(): string;
/**
* Returns the signature timestamp.
* @readonly
* @returns {string}
*/
readonly get sts(): string;
/**
* Returns the n-token decipher algorithm.
* @readonly
* @returns {string}
*/
readonly get ntoken_decipher(): string;
/**
* Returns the signature decipher algorithm.
* @readonly
* @returns {string}
*/
readonly get signature_decipher(): string;
isCached(): boolean;
#private;
}

63
typings/lib/core/PlaylistManager.d.ts vendored Normal file
View File

@@ -0,0 +1,63 @@
export = PlaylistManager;
/** @namespace */
declare class PlaylistManager {
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions: Actions);
/**
* Creates a playlist.
*
* @param {string} title
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
create(title: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Deletes a given playlist.
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
delete(playlist_id: string): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Adds videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
addVideos(playlist_id: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
/**
* Removes videos from a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
removeVideos(playlist_id: string, video_ids: Array<string>): Promise<{
success: boolean;
status_code: number;
playlist_id: string;
data: object;
}>;
#private;
}

23
typings/lib/core/SessionBuilder.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export = SessionBuilder;
/** @namespace */
declare class SessionBuilder {
/**
* @param {object} config
* @constructor
*/
constructor(config: object);
build(): Promise<SessionBuilder>;
/** @readonly */
readonly get key(): any;
/** @readonly */
readonly get context(): any;
/** @readonly */
readonly get api_version(): any;
/** @readonly */
readonly get client_version(): any;
/** @readonly */
readonly get client_name(): any;
/** @readonly */
readonly get player(): any;
#private;
}

12
typings/lib/deciphers/NToken.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export = NToken;
declare class NToken {
constructor(raw_code: any, n: any);
n: any;
raw_code: any;
/**
* Solves throttling challange by transforming the n token.
* @returns {string}
*/
transform(): string;
#private;
}

12
typings/lib/deciphers/Signature.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export = Signature;
declare class Signature {
constructor(url: any, sig_decipher_sc: any);
url: any;
sig_decipher_sc: any;
/**
* Deciphers signature.
* @returns {string}
*/
decipher(): string;
#private;
}

9
typings/lib/parser/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export = Parser;
declare class Parser {
constructor(session: any, data: any, args?: {});
data: any;
session: any;
args: {};
parse(): any;
#private;
}

11
typings/lib/parser/youtube/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import VideoResultItem = require("./search/VideoResultItem");
import SearchSuggestionItem = require("./search/SearchSuggestionItem");
import PlaylistItem = require("./others/PlaylistItem");
import NotificationItem = require("./others/NotificationItem");
import VideoItem = require("./others/VideoItem");
import GridVideoItem = require("./others/GridVideoItem");
import GridPlaylistItem = require("./others/GridPlaylistItem");
import ChannelMetadata = require("./others/ChannelMetadata");
import ShelfRenderer = require("./others/ShelfRenderer");
import CommentThread = require("./others/CommentThread");
export { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread };

View File

@@ -0,0 +1,15 @@
export = ChannelMetadata;
declare class ChannelMetadata {
static parse(data: any): {
title: any;
description: any;
metadata: {
url: any;
rss_urls: any;
vanity_channel_url: any;
external_id: any;
is_family_safe: any;
keywords: any;
};
};
}

View File

@@ -0,0 +1,23 @@
export = CommentThread;
declare class CommentThread {
static parseItem(item: any): {
text: any;
author: {
name: any;
thumbnails: any;
channel_id: any;
channel_url: string;
};
metadata: {
published: any;
is_reply: boolean;
is_liked: any;
is_disliked: any;
is_pinned: boolean;
is_channel_owner: any;
like_count: number;
reply_count: any;
id: any;
};
};
}

View File

@@ -0,0 +1,12 @@
export = GridPlaylistItem;
declare class GridPlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
metadata: {
thumbnail: any;
video_count: any;
};
};
}

View File

@@ -0,0 +1,25 @@
export = GridVideoItem;
declare class GridVideoItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnail: any;
moving_thumbnail: any;
published: any;
badges: any;
owner_badges: any;
};
};
}

View File

@@ -0,0 +1,14 @@
export = NotificationItem;
declare class NotificationItem {
static parse(data: any): any;
static parseItem(item: any): {
title: any;
sent_time: any;
channel_name: any;
channel_thumbnail: any;
video_thumbnail: any;
video_url: string;
read: any;
notification_id: any;
};
}

View File

@@ -0,0 +1,15 @@
export = PlaylistItem;
declare class PlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
thumbnails: any;
};
}

View File

@@ -0,0 +1,9 @@
export = ShelfRenderer;
declare class ShelfRenderer {
static parse(data: any): {
title: any;
videos: any;
};
static getTitle(data: any): any;
static parseItems(data: any): any;
}

View File

@@ -0,0 +1,31 @@
export = VideoItem;
declare class VideoItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
description: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnail: any;
moving_thumbnail: any;
published: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
badges: any;
owner_badges: any;
};
};
}

View File

@@ -0,0 +1,7 @@
export = SearchSuggestionItem;
declare class SearchSuggestionItem {
static parse(data: any): {
query: any;
results: any;
};
}

View File

@@ -0,0 +1,31 @@
export = VideoResultItem;
declare class VideoResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
url: string;
title: any;
description: any;
channel: {
id: any;
name: any;
url: string;
};
metadata: {
view_count: any;
short_view_count_text: {
simple_text: any;
accessibility_label: any;
};
thumbnails: any;
duration: {
seconds: number;
simple_text: any;
accessibility_label: any;
};
published: any;
badges: any;
owner_badges: any;
};
};
}

9
typings/lib/parser/ytmusic/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import SongResultItem = require("./search/SongResultItem");
import VideoResultItem = require("./search/VideoResultItem");
import AlbumResultItem = require("./search/AlbumResultItem");
import ArtistResultItem = require("./search/ArtistResultItem");
import PlaylistResultItem = require("./search/PlaylistResultItem");
import MusicSearchSuggestionItem = require("./search/MusicSearchSuggestionItem");
import TopResultItem = require("./search/TopResultItem");
import PlaylistItem = require("./others/PlaylistItem");
export { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,14 @@
export = PlaylistItem;
declare class PlaylistItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
duration: {
seconds: number;
simple_text: any;
};
thumbnails: any;
};
}

View File

@@ -0,0 +1,11 @@
export = AlbumResultItem;
declare class AlbumResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
year: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,10 @@
export = ArtistResultItem;
declare class ArtistResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
name: any;
subscribers: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,8 @@
export = MusicSearchSuggestionItem;
declare class MusicSearchSuggestionItem {
static parse(data: any): {
query: any;
results: any;
};
static parseItem(item: any): any;
}

View File

@@ -0,0 +1,11 @@
export = PlaylistResultItem;
declare class PlaylistResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
channel_id: any;
total_items: number;
};
}

View File

@@ -0,0 +1,12 @@
export = SongResultItem;
declare class SongResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
artist: any;
album: any;
duration: any;
thumbnails: any;
};
}

View File

@@ -0,0 +1,4 @@
export = TopResultItem;
declare class TopResultItem {
static parse(data: any): any;
}

View File

@@ -0,0 +1,12 @@
export = VideoResultItem;
declare class VideoResultItem {
static parse(data: any): any;
static parseItem(item: any): {
id: any;
title: any;
author: any;
views: any;
duration: any;
thumbnails: any;
};
}

113
typings/lib/proto/index.d.ts vendored Normal file
View File

@@ -0,0 +1,113 @@
export = Proto;
declare class Proto {
/**
* Encodes visitor data.
*
* @param {string} id
* @param {number} timestamp
*
* @returns {string}
*/
static encodeVisitorData(id: string, timestamp: number): string;
/**
* Encodes basic channel analytics parameters.
*
* @param {string} channel_id
* @returns {string}
*/
static encodeChannelAnalyticsParams(channel_id: string): string;
/**
* Encodes search filters.
*
* @param {object} filters
* @param {string} [filters.upload_date] - any | last_hour | today | this_week | this_month | this_year
* @param {string} [filters.type] - any | video | channel | playlist | movie
* @param {string} [filters.duration] - any | short | medium | long
* @param {string} [filters.sort_by] - relevance | rating | upload_date | view_count
* @todo implement remaining filters.
*
* @returns {string}
*/
static encodeSearchFilter(filters: {
upload_date?: string;
type?: string;
duration?: string;
sort_by?: string;
}): string;
/**
* Encodes livechat message parameters.
*
* @param {string} channel_id
* @param {string} video_id
*
* @returns {string}
*/
static encodeMessageParams(channel_id: string, video_id: string): string;
/**
* Encodes comment section parameters.
*
* @param {string} video_id
* @param {object} options
* @param {string} options.type
* @param {string} options.sort_by
*
* @returns {string}
*/
static encodeCommentsSectionParams(video_id: string, options?: {
type: string;
sort_by: string;
}): string;
/**
* Encodes replies thread parameters.
*
* @param {string} video_id
* @param {string} comment_id
*
* @returns {string}
*/
static encodeCommentRepliesParams(video_id: string, comment_id: string): string;
/**
* Encodes comment parameters.
*
* @param {string} video_id
* @returns {string}
*/
static encodeCommentParams(video_id: string): string;
/**
* Encodes comment reply parameters.
*
* @param {string} comment_id
* @param {string} video_id
*
* @return {string}
*/
static encodeCommentReplyParams(comment_id: string, video_id: string): string;
/**
* Encodes comment action parameters.
*
* @param {string} type
* @param {string} comment_id
* @param {string} video_id
* @param {string} [text]
* @param {string} [target_language]
*
* @returns {string}
*/
static encodeCommentActionParams(type: string, args?: {}): string;
/**
* Encodes notification preference parameters.
*
* @param {string} channel_id
* @param {number} index
*
* @returns {string}
*/
static encodeNotificationPref(channel_id: string, index: number): string;
/**
* Encodes sound info parameters.
*
* @param {string} id
* @returns {string}
*/
static encodeSoundInfoParams(id: string): string;
}

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