Compare commits

..

155 Commits

Author SHA1 Message Date
github-actions[bot]
4fca6c354e chore(main): release 10.4.0 (#721)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-26 23:41:20 -03:00
Luan
93906e0539 chore(docs): Mention BgUtils for generating PoTokens
https://github.com/LuanRT/BgUtils
2024-08-26 23:37:35 -03:00
dependabot[bot]
aaebcca90b chore(deps-dev): bump vite from 3.2.8 to 3.2.10 in /examples/browser/web (#739)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 3.2.8 to 3.2.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v3.2.10/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v3.2.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:52:34 -03:00
Luan
36121878b1 chore(examples): Fix proxy 2024-08-26 20:32:30 -03:00
Luan
d89909a19a fix(examples): Use BgUtils to generate pot [skip ci]
+ Improve readme.
2024-08-26 18:42:14 -03:00
Luan
367a6f7ec5 chore(package): Revert last commit
This reverts commit 05a663710d.
2024-08-23 14:44:00 -03:00
Luan
05a663710d chore(package): Use prepack instead of prepare 2024-08-23 14:22:54 -03:00
Luan
cc0fc7145a chore(package): Fix typo in exports 2024-08-23 13:17:50 -03:00
Luan
bdff3eae1c chore: lint [skip ci] 2024-08-23 02:07:41 -03:00
Luan
5a66d0ba93 chore: clean up some types [skip ci] 2024-08-23 02:04:28 -03:00
Luan
370cb0b29e chore(eslint): Update ignores 2024-08-23 01:12:17 -03:00
Luan
fcd00b0fb0 fix(FormatOptions): client missing some values 2024-08-23 01:05:37 -03:00
Luan
2dae5634f3 chore(docs): Minor rewording 2024-08-23 00:51:52 -03:00
Luan
2c43a5705f chore: update tests 2024-08-22 22:40:42 -03:00
Luan
83801ebc37 chore: fix linter config 2024-08-22 22:36:55 -03:00
Luan
c14a687e65 chore: bump eslint to v9 2024-08-22 15:43:59 -03:00
Dave Nicolson
f9ccba4af5 fix(ThumbnailOverlayResumePlayback): Update percent_duration_watched type (#737) 2024-08-19 20:18:06 -03:00
absidue
4b60b97132 feat(parser): Add VideoAttributesSectionView node (#732) 2024-08-12 14:49:20 -03:00
absidue
7afc3da80e fix(Session): Fix remote visitor data not gettting used (#731) 2024-08-12 00:21:02 -03:00
슈리튬
bb6e647b8c fix(Session): PoToken not being set correctly (#729) 2024-08-11 04:10:23 -03:00
Luan
f1973c11d9 fix(Session): Visitor data not being used properly 2024-08-10 11:12:13 -03:00
Luan
4942992630 refactor: Throw an error if an invalid client is specified 2024-08-08 10:11:38 -03:00
Luan
0e91a08ae2 fix(PlayerEndpoint): Don't set undefined fields 2024-08-08 09:01:14 -03:00
Luan
261f2ac12b feat(Utils): Add UMP parser
Currently not used anywhere in the project, but I figured I'd add it in case anyone wants to make their playback requests look more genuine by using UMP/SABR.
2024-08-08 07:57:14 -03:00
Luan
041aebc358 chore: Rephrase PoToken description 2024-08-08 07:42:20 -03:00
Émilien (perso)
c9f0ddd573 feat(Player): Add support for Proof of Identity tokens (#708)
* Fix different usages of potoken.

* Fix linting.

* Add mention about invidious youtube-trusted-session-generator.

---------

Co-authored-by: Luan <luan.lrt4@gmail.com>
2024-08-08 07:28:42 -03:00
Luan
25d268beba chore: update livechat example 2024-08-06 18:48:34 -03:00
absidue
2c0bb237e1 fix(Search): Fix it occasionally returning only a small number of results (#720) 2024-08-03 09:40:02 -03:00
github-actions[bot]
4f5635ad07 chore(main): release 10.3.0 (#704)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-01 06:21:32 -03:00
Luan
3b3cf1b2aa refactor(Player): Generate and parse player script's AST (#713)
Notes:
- The Syntax Tree is generated by Jinter (which is built on top of `Acorn`).
- While doing this may be slightly slower than using a regular exp, it is much more reliable (plus we already cache the player functions anyway).
2024-08-01 06:09:27 -03:00
Dave Nicolson
d85fbc56cf feat(PlaylistManager): Add ability to remove videos by set ID (#715) 2024-08-01 06:07:47 -03:00
Luan
e55d4af100 chore: lint 2024-08-01 06:03:51 -03:00
Armel Chesnais
04369be620 fix(Player): Address changes introduced by player id 20dfca59 (#712)
Fixes the nSig extract for YT player id `20dfca59 `
Handles:

String.prototype.split.call(a,("",""))

and

Array.prototype.join.call(b,
("",""))};

Note the newline

Co-authored-by: Luan <luan.lrt4@gmail.com>
2024-07-31 06:38:32 -03:00
Luan
a89a5ac2dd refactor(Player.ts): Handle nsig failure gracefully
Preping for future changes.
2024-07-31 06:18:18 -03:00
Luan
5529a6aca0 chore(Player): Don't throw an error if nsig extraction fails
This is called when an InnerTube instance is created, so throwing here breaks the entire library.
2024-07-30 19:05:29 -03:00
Luan
94a6765c97 chore: update tests 2024-07-30 18:50:03 -03:00
Luan
9b9fb82131 refactor: Clean up & fix old code
Other changes:
- Renamed "getShortsWatchItem" to "getShortsVideoInfo".
- Fixed `ShortFormVideoInfo`. This never worked for me ever since it was introduced. Turned out it was just implemented incorrectly.
- Moved `basic_info` extraction to `MediaInfo`. Less of a pain to maintain as we only have to modify one file.
- Removed unneeded tsdoc comments.
- Fixed `Innertube#getStreamingData()`. Now it actually returns a deciphered format.
- Simplified some types.
2024-07-30 18:49:24 -03:00
absidue
3153375bca fix(HTTPClient): Adjust more context fields for the iOS client (#705) 2024-07-26 11:15:12 -03:00
Dave Nicolson
a9bf225a62 feat(parser): Add EomSettingsDisclaimer node (#703) 2024-07-25 22:12:57 -03:00
github-actions[bot]
1e29019a07 chore(main): release 10.2.0 (#688)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-25 10:52:50 -03:00
Luan
6765f4e0d7 fix(Player): Bump cache version (#702)
We should always do this after updating the sig/nsig code, it's so that the old cache gets ignored : ).
2024-07-25 10:48:24 -03:00
absidue
3048f70f60 fix(Player): Fix extracting the n-token decipher algorithm again (#701) 2024-07-25 10:07:00 -03:00
Brahim Hadriche
090539b28f feat(parser): add classdata to unhandled parse errors (#691) 2024-07-24 15:55:20 -03:00
Brahim Hadriche
6d0bc89be1 fix(parser): ignore MiniGameCardView node (#692) 2024-07-24 15:54:37 -03:00
GurumNyang
a5f62093a1 feature(proto): Add comment_id to commentSectionParams (#693) 2024-07-24 15:54:14 -03:00
absidue
a352ddeb9d feat(Format): Add is_secondary for detecting secondary audio tracks (#697) 2024-07-24 15:53:27 -03:00
Brahim Hadriche
0f8f92a28a fix(parser): ThumbnailView background color (#686) 2024-07-11 14:31:27 -03:00
github-actions[bot]
7d03469e64 chore(main): release 10.1.0 (#669)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-10 03:44:43 -03:00
Luan
62ac2f6f32 fix(proto): Update Context message
Closes #681
2024-07-10 03:41:16 -03:00
absidue
142a7d0428 fix(Player): Fix extracting the n-token decipher algorithm (#682)
* fix(Player): Fix extracting the n-token decipher algorithm

* fix: bump Jinter to v2

---------

Co-authored-by: Luan <luan.lrt4@gmail.com>
2024-07-10 02:21:39 -03:00
Luan
efa7205723 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-07-01 08:53:45 -03:00
Luan
84f90aaf29 fix(Session): Round UTC offset minutes 2024-07-01 08:53:08 -03:00
absidue
858cdd197c feat(toDash): Add the "dub" role to translated captions (#677) 2024-06-30 23:05:08 -03:00
Luan
5a8fd3ad37 feat(Session): Add configInfo to InnerTube context
Minor addition. It's needed for certain UMP requests.
2024-06-30 22:51:02 -03:00
슈리튬
a19511de24 fix(FormatUtils): Throw an error if download requests fails 2024-06-28 16:45:39 -03:00
absidue
bd9f6ac64c feat(toDash): Add option to include WebVTT or TTML captions (#673) 2024-06-25 01:22:11 -03:00
absidue
e5aab9a9b3 fix(toDash): Fix image representations not being spec compliant (#672) 2024-06-24 15:48:38 -03:00
Luan
d6fa134c3d chore(Playlist): Add MusicResponsiveHeader to header types
Oops! I forgot this one also existed : ).
2024-06-21 21:12:54 -03:00
Luan
fe953072a2 chore: fix tests 2024-06-21 20:57:38 -03:00
Luan
055fa33403 chore: lint 2024-06-21 19:32:50 -03:00
Luan
14c3a06d40 fix(YTMusic): Add support for new header layouts
This is due to a minor page redesign by YouTube Music. See https://9to5google.com/2024/06/20/youtube-music-web-album-playlist-redesign/.
2024-06-21 19:31:40 -03:00
Luan
67376afae6 chore(Format): Clean up and add some extra fields 2024-06-16 16:22:33 -03:00
Luan
4cbaa7983f fix(InfoPanelContent): Update InfoPanelContent node to also support paragraphs
This would fail when `attributedParagraphs` was missing, so we still need `paragraphs` there.
2024-06-16 15:39:47 -03:00
github-actions[bot]
9802483233 chore(main): release 10.0.0 (#658)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-06-09 20:43:14 -03:00
Aidar Nugmanoff
2980a608b6 feat(Platform): Add support for react-native platform (#593) 2024-06-09 18:15:57 -03:00
Luan
b6cecb10f5 chore(docs): update readme 2024-06-09 18:02:46 -03:00
Luan
040a091639 fix(FlexibleActionsView): Update actions array type to include ToggleButtonView 2024-06-08 16:33:34 -03:00
Luan
3939405cc6 chore(Player): Rephrase nsig failure message 2024-06-07 14:26:27 -03:00
Luan
978ab1ed29 chore(docs): fix typo [skip ci] 2024-06-07 14:18:23 -03:00
Luan
5cdb9e1e2f fix(InfoPanelContainer): Use new attributed text prop
+ And update other related nodes.
2024-06-07 14:15:44 -03:00
Luan
15f3b5fdba fix(ButtonView): Rename type property to button_type
It was overriding the static property "type".
2024-06-05 16:00:16 -03:00
Luan
384b80ee41 fix(Cache): Use TextEncoder to encode compressed data 2024-06-05 12:30:12 -03:00
Luan
b588554ce1 chore: update docs [skip ci] 2024-06-03 19:16:41 -03:00
Luan
583fd9f8d7 fix(MusicResponsiveHeader): Add Text import
Looks like I forgot to add it.
2024-06-03 19:08:33 -03:00
Luan
7953296580 feat(Session): Add enable_session_cache option (#664)
See https://github.com/LuanRT/YouTube.js/pull/663#issuecomment-2146161637
2024-06-03 19:04:30 -03:00
Luan
cf29664d37 perf(general): Add session cache and LZW compression (#663)
* feat(utils): Implement LZW compression module

* feat(Session): Implement cache for sessions
This should improve performance quite a bit for those who are not using the `generate_session_locally` option (like me :P).

* refactor(Player): Add LZW compression
This considerably reduces the size of the cache.
2024-06-03 18:21:48 -03:00
Luan
4015a5e560 chore(JsRuntime): Change log levels in evaluate function 2024-06-03 17:42:18 -03:00
Luan
184df79b3a refactor(HTTPClient): Use getCookie fn to get SAPISID token 2024-06-03 17:41:09 -03:00
Luan
000f3f0915 refactor(Artist.ts): Change sections type to ObservedArray<MusicCarouselShelf | MusicShelf> 2024-06-03 17:39:42 -03:00
Luan
8372b3d22f Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-06-03 17:33:04 -03:00
Luan
b9d50daa57 chore: clean up
Updated deps, fixed some ts issues, renamed "scripts" to "dev-scripts", and added a script to delete build output.
2024-06-03 17:29:11 -03:00
absidue
031ffb696e feat(toDash): Add support for stable volume/DRC (#662) 2024-05-28 02:43:10 -03:00
Luan
8e942ada3b chore(docs): fix some markdown issues 2024-05-24 04:33:03 -03:00
LuanRT
aa3f34c428 chore: Fix browser example 2024-05-23 21:00:47 -03:00
LuanRT
c82bb70180 chore(HTTPClient): Remove env check when setting Android headers
These requests are supposed to be proxied, so there's no need to worry about browsers not liking it.
2024-05-23 20:58:37 -03:00
LuanRT
766045049d refactor(Innertube#getPlaylists)!: Return a Feed instance instead of items 2024-05-21 20:50:41 -03:00
Luan
b6ce5f903f refactor(OAuth2)!: Rewrite auth module (#661)
This is a rewrite of the OAuth2 module to address some bugs and inconsistencies. And since it changes the structure of the credentials, I'm marking this as a breaking change.

Note that you will have to update your existing credentials, that is if you wish to continue using them. Otherwise, simply delete them and sign in again.
2024-05-21 18:47:31 -03:00
absidue
6bb2086875 feat(Format): Add is_drc (#656) 2024-05-06 11:55:58 -03:00
Brahim Hadriche
810665407e Item section target_id fix (#655) 2024-04-29 14:22:28 -03:00
github-actions[bot]
1b00e2c6ce chore(main): release 9.4.0 (#644)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-29 09:14:43 -03:00
LuanRT
ea82beaa10 feat(Parser): Add MusicResponsiveHeader node 2024-04-29 08:24:13 -03:00
absidue
0ba8c54257 feat(Format): Add spatial_audio_type (#647) 2024-04-29 08:10:08 -03:00
Brahim Hadriche
7315fca1b4 Add getPlaylists function (#650) 2024-04-29 08:09:35 -03:00
Brahim Hadriche
0602dd2c3d Lint fix (#651) 2024-04-29 08:07:24 -03:00
LuanRT
13321888e8 chore(PlayerEndpoint): Remove outdated code 2024-04-29 08:05:59 -03:00
absidue
d48b9d0946 chore(HTTPClient): Add X-Youtube-Client-Name and remove X-Origin headers (#645) 2024-04-25 18:04:10 -03:00
LuanRT
592ddac30f chore: Fix tests
Oops :)
2024-04-19 16:37:38 -03:00
LuanRT
1ec2ea85e2 refactor(Music#getRelated): Return page contents directy 2024-04-19 16:22:21 -03:00
absidue
064436cef3 feat(Format): Add projection_type and stereo_layout (#643)
5930ebda46
2024-04-19 16:08:12 -03:00
ChunkyProgrammer
4022d7aa89 Remove test code (#636) 2024-04-11 23:29:46 -03:00
github-actions[bot]
cd69ce73c1 chore(main): release 9.3.0 (#635)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-11 18:19:12 -03:00
LuanRT
1c08bfe113 feat(CommentView): Implement comment interaction methods 2024-04-11 18:04:45 -03:00
LuanRT
a624963384 docs(Comments): Update API ref 2024-04-11 18:03:04 -03:00
LuanRT
66e34f9388 fix(CommentThread): Replies not being parsed correctly 2024-04-11 16:05:59 -03:00
github-actions[bot]
0c2cdc1599 chore(main): release 9.2.1 (#632)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-09 17:42:18 -03:00
absidue
010704929f fix(toDash): Add missing transfer characteristics for h264 streams (#631) 2024-04-09 17:41:08 -03:00
dependabot[bot]
d4a938771b chore(deps): bump undici from 5.28.3 to 5.28.4 (#627)
Bumps [undici](https://github.com/nodejs/undici) from 5.28.3 to 5.28.4.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.28.3...v5.28.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 15:02:28 -03:00
github-actions[bot]
5ecfb08772 chore(main): release 9.2.0 (#611)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-31 11:39:15 -03:00
Ayoub
2029aec90d feat: add support of cloudflare workers (#596) 2024-03-31 11:37:06 -03:00
Luan
d589365ea2 fix(PlayerEndpoint): Workaround for "The following content is not available on this app" (Android) (#624)
* chore: Update Android client version and UA

* refactor: Update shorts parameter protobuf

* chore: Update auto generated files

* chore: Add test

* chore: Update comments test id (unrelated)

* chore: Update comments test again (unrelated)
2024-03-31 11:35:12 -03:00
LuanRT
45f33d8c04 refactor(MusicResponsiveListItem): Improve podcast and video/song parsing 2024-03-25 11:55:06 -03:00
LuanRT
92117eaaa0 chore(tests): use test instead of describe 2024-03-25 08:26:29 -03:00
LuanRT
39725374e3 chore(tests): remove beforeAll for the home feed test 2024-03-25 08:23:59 -03:00
LuanRT
213d78b1ab chore: remove home feed continuation test
Home feed now requires a visitor id with reputation or an account.

Removing this for now until I find a way around it for the tests at least.
2024-03-25 08:19:14 -03:00
LuanRT
28f53a698d chore: remove API key parameter
No longer needed.
2024-03-25 08:07:49 -03:00
Adam Learns
776a156f65 Fix broken README links (#618)
There were apostrophes in the links.
2024-03-25 07:25:17 -03:00
absidue
4a9bd32fd7 chore(LockupView): Remove debug logging (#617) 2024-03-25 07:24:24 -03:00
WhiteMind
3170659880 fix(Cache): handle the value read from the db correctly according to its type (#620) 2024-03-25 07:23:56 -03:00
absidue
e6f1f078a8 feat(Text): Support formatting and emojis in fromAttributed (#615) 2024-03-25 07:22:24 -03:00
absidue
900f557202 feat(parser): Support CommentView nodes (#614) 2024-03-25 07:20:29 -03:00
absidue
7ca2a0c3e4 feat(parser): Support LockupView and it's child nodes (#609) 2024-02-29 13:29:53 -03:00
LuanRT
f95283b236 chore: add any-of-issue-labels option to stale workflow 2024-02-22 23:04:46 -03:00
LuanRT
f6a7bcc44a Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-02-22 22:47:36 -03:00
LuanRT
c444843799 chore: update workflows 2024-02-22 22:47:17 -03:00
github-actions[bot]
5fe91d6642 chore(main): release 9.1.0 (#600)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-02-22 22:29:37 -03:00
absidue
bff65f8889 feat(Format): Support caption tracks in adaptive formats (#598) 2024-02-22 22:28:16 -03:00
dependabot[bot]
dac5eb712d chore(deps): bump undici from 5.27.0 to 5.28.3 (#599)
Bumps [undici](https://github.com/nodejs/undici) from 5.27.0 to 5.28.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.27.0...v5.28.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 22:27:47 -03:00
LuanRT
2068dfb73e fix(Session): Don't try to extract api version from service worker
It doesn't make sense to do this anyway because if it ever changed, we'd probably have to refactor the entire library.

Closes #602, #603, #604
2024-02-22 22:25:30 -03:00
LuanRT
3e84775fd3 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-02-18 23:37:13 -03:00
LuanRT
89fa3b27a8 fix(Playlist): items getter failing if a playlist contains Shorts 2024-02-18 23:36:01 -03:00
github-actions[bot]
ab7201f0cc chore(main): release 9.0.2 (#591)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-31 19:50:59 -03:00
absidue
b21eb9f33d fix(VideoInfo): Fix error because of typo in getWatchNextContinuation (#590) 2024-01-31 19:34:46 -03:00
LuanRT
0751793380 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-01-29 22:19:35 -03:00
LuanRT
5c91c2af4a chore: merge main 2024-01-29 22:14:22 -03:00
LuanRT
47cad4c6e1 chore: lint build scripts [skip ci] 2024-01-29 22:11:20 -03:00
github-actions[bot]
4fb9dff0f2 chore(main): release 9.0.1 (#588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-25 21:32:45 -03:00
LuanRT
81dd5d3288 fix(build): Circular imports causing issues with webpack 2024-01-25 21:30:14 -03:00
LuanRT
c7f42220db chore: revert unneeded import type changes & lint
Yes. Again.
2024-01-25 21:17:59 -03:00
LuanRT
5204b29e81 chore: Lint 2024-01-25 20:47:19 -03:00
LuanRT
cbaa838cee chore: Revert some unneeded import changes 2024-01-25 20:43:19 -03:00
github-actions[bot]
379e63d2f6 chore(main): release 9.0.0 (#572)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-25 19:04:29 -03:00
Luan
e86a0daf45 refactor(general): Clean up and add a logger (#587)
* feat(utils): Add logger

* chore: Clean up some classes and add more logging

* chore: Fix conflicts
2024-01-25 19:01:28 -03:00
absidue
7fbc37f9d1 fix(PlayerCaptionTracklist): Fix captions_tracks[].kind type (#586) 2024-01-20 01:44:54 -03:00
absidue
2e710dc9f7 feat(Channel): Support getting about with PageHeader (#581) 2024-01-18 14:58:58 -03:00
absidue
fed3512461 fix(DecoratedAvatarView): Fix parsing and optional properties (#584) 2024-01-18 14:55:10 -03:00
absidue
6dd03e1658 feat(toDash)!: Add support for generating manifests for Post Live DVR videos (#580)
BREAKING CHANGES: The `duration` property in `StreamingInfo` has been
replaced by the asynchronous `getDuration()` function, as getting the duration
of Post Live DVR videos requires making a fetch request.
2024-01-18 14:51:42 -03:00
absidue
2073aa910a feat(parser): Add ImageBannerView (#583) 2024-01-18 14:41:08 -03:00
absidue
f7b7bbd47a chore(Constants): Update web client version (#582) 2024-01-18 14:40:15 -03:00
Luan
04d55d04c7 refactor(Playlist): Ignore ContinuationItem nodes from SectionList#contents (#579)
* feat(PlaylistVideo): Add `style`

* refactor(Playlist): Ignore `ContinuationItem` nodes in `SectionList#contents`

This should fix some issues regarding the library fetching the wrong continuation or empty continuations (NOTE: This means the solution in 987f506 no longer applies as empty continuations were all in `SectionList#contents`).
2024-01-18 14:39:25 -03:00
absidue
6082b4a52e feat(Channel): Support PageHeader being used on user channels (#577) 2024-01-12 21:52:02 -03:00
absidue
3980f97b8f fix(proto): Fix visitor data base64url decoding (#576) 2024-01-12 14:42:50 -03:00
absidue
59f4cfb4db fix(toDash): Add missing transfer characteristics for h264 streams (#573) 2024-01-10 20:17:21 -03:00
absidue
254f77944f feat(VideoDetails): Add is_live_dvr_enabled, is_low_latency_live_stream and live_chunk_readahead (#569) 2024-01-10 11:49:14 -03:00
absidue
586bb5f139 feat(Format): Add max_dvr_duration_sec and target_duration_dec (#570) 2024-01-10 11:40:08 -03:00
absidue
562e6a20f0 feat(VideoInfo): Add live stream end_timestamp (#571) 2024-01-10 11:39:47 -03:00
194 changed files with 8976 additions and 7338 deletions

View File

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

View File

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

View File

@@ -13,6 +13,6 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
node-version: 20
- run: npm ci
- run: npm run lint

View File

@@ -13,5 +13,6 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
stale-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'
any-of-issue-labels: 'needs-more-info,cannot-reproduce,question,help-wanted'
days-before-stale: 60
days-before-close: 4

View File

@@ -13,6 +13,6 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
node-version: 20
- run: npm ci
- run: npm run test

3
.gitignore vendored
View File

@@ -56,6 +56,7 @@ pnpm-lock.yaml
# Downloaded assets
*.mp4
*.m4a
*.webm
*.mkv
@@ -75,3 +76,5 @@ deno/
# MacOS
.DS_Store
*.bin

View File

@@ -1,5 +1,208 @@
# Changelog
## [10.4.0](https://github.com/LuanRT/YouTube.js/compare/v10.3.0...v10.4.0) (2024-08-27)
### Features
* **parser:** Add `VideoAttributesSectionView` node ([#732](https://github.com/LuanRT/YouTube.js/issues/732)) ([4b60b97](https://github.com/LuanRT/YouTube.js/commit/4b60b97132b0ee42b41838f3336c582a7f7f7aec))
* **Player:** Add support for Proof of Identity tokens ([#708](https://github.com/LuanRT/YouTube.js/issues/708)) ([c9f0ddd](https://github.com/LuanRT/YouTube.js/commit/c9f0ddd573de297c0b384e422e6c1737454926e2))
* **Utils:** Add `UMP` parser ([261f2ac](https://github.com/LuanRT/YouTube.js/commit/261f2ac12b6a9a5bd5f7a43557018de333f7bec3))
### Bug Fixes
* **examples:** Use BgUtils to generate pot [skip ci] ([d89909a](https://github.com/LuanRT/YouTube.js/commit/d89909a19a1486bee7e3275014725b4e3dc2cbe2))
* **FormatOptions:** `client` missing some values ([fcd00b0](https://github.com/LuanRT/YouTube.js/commit/fcd00b0fb0f88a16c27da1ed89e9a2c4887e5c52))
* **PlayerEndpoint:** Don't set `undefined` fields ([0e91a08](https://github.com/LuanRT/YouTube.js/commit/0e91a08ae2194a07defc4b1e12ff3edbe13b72df))
* **Search:** Fix it occasionally returning only a small number of results ([#720](https://github.com/LuanRT/YouTube.js/issues/720)) ([2c0bb23](https://github.com/LuanRT/YouTube.js/commit/2c0bb237e1d0eb160dc3f879f5cab2022d9b5b04))
* **Session:** `PoToken` not being set correctly ([#729](https://github.com/LuanRT/YouTube.js/issues/729)) ([bb6e647](https://github.com/LuanRT/YouTube.js/commit/bb6e647b8c88753669acde43d0d648aaf11caba6))
* **Session:** Fix remote visitor data not gettting used ([#731](https://github.com/LuanRT/YouTube.js/issues/731)) ([7afc3da](https://github.com/LuanRT/YouTube.js/commit/7afc3da80ee3b5aa6edd2a899be82c1a21e03556))
* **Session:** Visitor data not being used properly ([f1973c1](https://github.com/LuanRT/YouTube.js/commit/f1973c11d9a492898b5e72b1f2b79291b674e229))
* **ThumbnailOverlayResumePlayback:** Update `percent_duration_watched` type ([#737](https://github.com/LuanRT/YouTube.js/issues/737)) ([f9ccba4](https://github.com/LuanRT/YouTube.js/commit/f9ccba4af5268d67d8610a1a5d623964f56d170d))
## [10.3.0](https://github.com/LuanRT/YouTube.js/compare/v10.2.0...v10.3.0) (2024-08-01)
### Features
* **parser:** Add `EomSettingsDisclaimer` node ([#703](https://github.com/LuanRT/YouTube.js/issues/703)) ([a9bf225](https://github.com/LuanRT/YouTube.js/commit/a9bf225a62108e47a50316235a83a814c682d745))
* **PlaylistManager:** Add ability to remove videos by set ID ([#715](https://github.com/LuanRT/YouTube.js/issues/715)) ([d85fbc5](https://github.com/LuanRT/YouTube.js/commit/d85fbc56cf0fd7367b182ae36e65c1701bc5e62d))
### Bug Fixes
* **HTTPClient:** Adjust more context fields for the iOS client ([#705](https://github.com/LuanRT/YouTube.js/issues/705)) ([3153375](https://github.com/LuanRT/YouTube.js/commit/3153375bcaa6c03afba9da8474e6a9d37471ed29))
## [10.2.0](https://github.com/LuanRT/YouTube.js/compare/v10.1.0...v10.2.0) (2024-07-25)
### Features
* **Format:** Add `is_secondary` for detecting secondary audio tracks ([#697](https://github.com/LuanRT/YouTube.js/issues/697)) ([a352dde](https://github.com/LuanRT/YouTube.js/commit/a352ddeb9db001e99f49025048ad0942d84f1b3e))
* **parser:** add classdata to unhandled parse errors ([#691](https://github.com/LuanRT/YouTube.js/issues/691)) ([090539b](https://github.com/LuanRT/YouTube.js/commit/090539b28f9bc3387d01e37325ba5741b33b1765))
* **proto:** Add `comment_id` to commentSectionParams ([#693](https://github.com/LuanRT/YouTube.js/issues/693)) ([a5f6209](https://github.com/LuanRT/YouTube.js/commit/a5f62093a18705fc822abd86beaa81788b6535ce))
### Bug Fixes
* **parser:** ignore MiniGameCardView node ([#692](https://github.com/LuanRT/YouTube.js/issues/692)) ([6d0bc89](https://github.com/LuanRT/YouTube.js/commit/6d0bc89be18f27a8ce74517f5cab5020d6790328))
* **parser:** ThumbnailView background color ([#686](https://github.com/LuanRT/YouTube.js/issues/686)) ([0f8f92a](https://github.com/LuanRT/YouTube.js/commit/0f8f92a28a5b6143e890626b22ce570730a0cf09))
* **Player:** Bump cache version ([#702](https://github.com/LuanRT/YouTube.js/issues/702)) ([6765f4e](https://github.com/LuanRT/YouTube.js/commit/6765f4e0d791c657fc7411e9cdd2c0f9284e9982))
* **Player:** Fix extracting the n-token decipher algorithm again ([#701](https://github.com/LuanRT/YouTube.js/issues/701)) ([3048f70](https://github.com/LuanRT/YouTube.js/commit/3048f70f60756884bd7b591d770f7b6343cfa259))
## [10.1.0](https://github.com/LuanRT/YouTube.js/compare/v10.0.0...v10.1.0) (2024-07-10)
### Features
* **Session:** Add `configInfo` to InnerTube context ([5a8fd3a](https://github.com/LuanRT/YouTube.js/commit/5a8fd3ad37bce1decad28ec3727453ddd430a561))
* **toDash:** Add option to include WebVTT or TTML captions ([#673](https://github.com/LuanRT/YouTube.js/issues/673)) ([bd9f6ac](https://github.com/LuanRT/YouTube.js/commit/bd9f6ac64ca9ba96e856aabe5fcc175fd9c294dc))
* **toDash:** Add the "dub" role to translated captions ([#677](https://github.com/LuanRT/YouTube.js/issues/677)) ([858cdd1](https://github.com/LuanRT/YouTube.js/commit/858cdd197cb2bb1e1d7a7285966cb56043ad8961))
### Bug Fixes
* **FormatUtils:** Throw an error if download requests fails ([a19511d](https://github.com/LuanRT/YouTube.js/commit/a19511de24bb82007aab072844efe64bbb8698da))
* **InfoPanelContent:** Update InfoPanelContent node to also support `paragraphs` ([4cbaa79](https://github.com/LuanRT/YouTube.js/commit/4cbaa7983f35a82b9907197769672ac3b300bfbe))
* **Player:** Fix extracting the n-token decipher algorithm ([#682](https://github.com/LuanRT/YouTube.js/issues/682)) ([142a7d0](https://github.com/LuanRT/YouTube.js/commit/142a7d042885188605bdc0655d3733502d1e20fa))
* **proto:** Update `Context` message ([62ac2f6](https://github.com/LuanRT/YouTube.js/commit/62ac2f6f32d35fec3c31b5f5d556bd4569fa49f9)), closes [#681](https://github.com/LuanRT/YouTube.js/issues/681)
* **Session:** Round UTC offset minutes ([84f90aa](https://github.com/LuanRT/YouTube.js/commit/84f90aaf2908ecacb9dfb6ce5497c4c4d14a72c3))
* **toDash:** Fix image representations not being spec compliant ([#672](https://github.com/LuanRT/YouTube.js/issues/672)) ([e5aab9a](https://github.com/LuanRT/YouTube.js/commit/e5aab9a9b35f0752cd5ca50bfa25936dce4718c6))
* **YTMusic:** Add support for new header layouts ([14c3a06](https://github.com/LuanRT/YouTube.js/commit/14c3a06d402989e98a9d32c79b2dc26f74fb0219))
## [10.0.0](https://github.com/LuanRT/YouTube.js/compare/v9.4.0...v10.0.0) (2024-06-09)
### ⚠ BREAKING CHANGES
* **Innertube#getPlaylists:** Return a `Feed` instance instead of items
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661))
### Features
* **Format:** Add `is_drc` ([#656](https://github.com/LuanRT/YouTube.js/issues/656)) ([6bb2086](https://github.com/LuanRT/YouTube.js/commit/6bb2086875d089f47c5f86ce94db9e32cb051319))
* **Platform:** Add support for `react-native` platform ([#593](https://github.com/LuanRT/YouTube.js/issues/593)) ([2980a60](https://github.com/LuanRT/YouTube.js/commit/2980a608b67f18416d7f73f1bdbcf4b897307b26))
* **Session:** Add `enable_session_cache` option ([#664](https://github.com/LuanRT/YouTube.js/issues/664)) ([7953296](https://github.com/LuanRT/YouTube.js/commit/795329658033652625d2d61b275ccf703573a437))
* **toDash:** Add support for stable volume/DRC ([#662](https://github.com/LuanRT/YouTube.js/issues/662)) ([031ffb6](https://github.com/LuanRT/YouTube.js/commit/031ffb696e3b7e160779e8b55a49b0cfa9f95620))
### Bug Fixes
* **ButtonView:** Rename `type` property to `button_type` ([15f3b5f](https://github.com/LuanRT/YouTube.js/commit/15f3b5fdba17f11cddada168de268546875e48f9))
* **Cache:** Use `TextEncoder` to encode compressed data ([384b80e](https://github.com/LuanRT/YouTube.js/commit/384b80ee41d7547a00d8dc17c50c8542629264b5))
* **FlexibleActionsView:** Update actions array type to include `ToggleButtonView` ([040a091](https://github.com/LuanRT/YouTube.js/commit/040a09163903b914f546d5083dbfdeab7175b24c))
* **InfoPanelContainer:** Use new attributed text prop ([5cdb9e1](https://github.com/LuanRT/YouTube.js/commit/5cdb9e1e2fa4ad5abdb3659bb35d0b3edc60123c))
* **ItemSection:** Fix `target_id` not being set because of a typo. ([#655](https://github.com/LuanRT/YouTube.js/issues/655)) ([8106654](https://github.com/LuanRT/YouTube.js/commit/810665407e91b2890a8e555fd759d67ccd800379))
* **MusicResponsiveHeader:** Add `Text` import ([583fd9f](https://github.com/LuanRT/YouTube.js/commit/583fd9f8d70735d071b34bd1d68faa62eeac593a))
### Performance Improvements
* **general:** Add session cache and LZW compression ([#663](https://github.com/LuanRT/YouTube.js/issues/663)) ([cf29664](https://github.com/LuanRT/YouTube.js/commit/cf29664d376ff792602400ef9a4ac301c676735c))
### Code Refactoring
* **Innertube#getPlaylists:** Return a `Feed` instance instead of items ([7660450](https://github.com/LuanRT/YouTube.js/commit/766045049d7154866e6fe32f6d965025d736d77d))
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661)) ([b6ce5f9](https://github.com/LuanRT/YouTube.js/commit/b6ce5f903fa2285cb381d73aedf02cc5e2712478))
## [9.4.0](https://github.com/LuanRT/YouTube.js/compare/v9.3.0...v9.4.0) (2024-04-29)
### Features
* **Format:** Add `projection_type` and `stereo_layout` ([#643](https://github.com/LuanRT/YouTube.js/issues/643)) ([064436c](https://github.com/LuanRT/YouTube.js/commit/064436cef30e892d8f569d4f7b146557fd72b09f))
* **Format:** Add `spatial_audio_type` ([#647](https://github.com/LuanRT/YouTube.js/issues/647)) ([0ba8c54](https://github.com/LuanRT/YouTube.js/commit/0ba8c54257b068d7e4518c982396acb42f1dd41d))
* **Parser:** Add `MusicResponsiveHeader` node ([ea82bea](https://github.com/LuanRT/YouTube.js/commit/ea82beaa10f6c877d6dd3102e10f6ae382560e0f))
## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)
### Features
* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))
### Bug Fixes
* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)
### Bug Fixes
* **toDash:** Add missing transfer characteristics for h264 streams ([#631](https://github.com/LuanRT/YouTube.js/issues/631)) ([0107049](https://github.com/LuanRT/YouTube.js/commit/010704929fa4b737f68a5a1f10bf0b166cfbf905))
## [9.2.0](https://github.com/LuanRT/YouTube.js/compare/v9.1.0...v9.2.0) (2024-03-31)
### Features
* add support of cloudflare workers ([#596](https://github.com/LuanRT/YouTube.js/issues/596)) ([2029aec](https://github.com/LuanRT/YouTube.js/commit/2029aec90de3c0fdb022094d7b704a2feed4133b))
* **parser:** Support CommentView nodes ([#614](https://github.com/LuanRT/YouTube.js/issues/614)) ([900f557](https://github.com/LuanRT/YouTube.js/commit/900f5572021d348e7012909f2080e52eac06adae))
* **parser:** Support LockupView and it's child nodes ([#609](https://github.com/LuanRT/YouTube.js/issues/609)) ([7ca2a0c](https://github.com/LuanRT/YouTube.js/commit/7ca2a0c3e43ebd4b9443e69b7432f302b09e9c7a))
* **Text:** Support formatting and emojis in `fromAttributed` ([#615](https://github.com/LuanRT/YouTube.js/issues/615)) ([e6f1f07](https://github.com/LuanRT/YouTube.js/commit/e6f1f078a828f8ea5db1fe7aec9f677bc53694e3))
### Bug Fixes
* **Cache:** handle the value read from the db correctly according to its type ([#620](https://github.com/LuanRT/YouTube.js/issues/620)) ([3170659](https://github.com/LuanRT/YouTube.js/commit/317065988007c860bf6173b0ac3c3d685ed81d20))
* **PlayerEndpoint:** Workaround for "The following content is not available on this app" (Android) ([#624](https://github.com/LuanRT/YouTube.js/issues/624)) ([d589365](https://github.com/LuanRT/YouTube.js/commit/d589365ea27f540ff36e33a65362c932cd28c274))
## [9.1.0](https://github.com/LuanRT/YouTube.js/compare/v9.0.2...v9.1.0) (2024-02-23)
### Features
* **Format:** Support caption tracks in adaptive formats ([#598](https://github.com/LuanRT/YouTube.js/issues/598)) ([bff65f8](https://github.com/LuanRT/YouTube.js/commit/bff65f8889c32813ec05bd187f3a4386fc6127c0))
### Bug Fixes
* **Playlist:** `items` getter failing if a playlist contains Shorts ([89fa3b2](https://github.com/LuanRT/YouTube.js/commit/89fa3b27a839d98aaf8bd70dd75220ee309c2bea))
* **Session:** Don't try to extract api version from service worker ([2068dfb](https://github.com/LuanRT/YouTube.js/commit/2068dfb73eefc0e40157421d4e5b4096c0c8429c))
## [9.0.2](https://github.com/LuanRT/YouTube.js/compare/v9.0.1...v9.0.2) (2024-01-31)
### Bug Fixes
* **VideoInfo:** Fix error because of typo in getWatchNextContinuation ([#590](https://github.com/LuanRT/YouTube.js/issues/590)) ([b21eb9f](https://github.com/LuanRT/YouTube.js/commit/b21eb9f33d956e130bac98712384125ae04ace47))
## [9.0.1](https://github.com/LuanRT/YouTube.js/compare/v9.0.0...v9.0.1) (2024-01-26)
### Bug Fixes
* **build:** Circular imports causing issues with webpack ([81dd5d3](https://github.com/LuanRT/YouTube.js/commit/81dd5d3288771132e7fb904b620e58277f639ccc))
## [9.0.0](https://github.com/LuanRT/YouTube.js/compare/v8.2.0...v9.0.0) (2024-01-25)
### ⚠ BREAKING CHANGES
* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580))
### Features
* **Channel:** Support getting about with PageHeader ([#581](https://github.com/LuanRT/YouTube.js/issues/581)) ([2e710dc](https://github.com/LuanRT/YouTube.js/commit/2e710dc9f7e206627f189df19be17009b270bc8b))
* **Channel:** Support PageHeader being used on user channels ([#577](https://github.com/LuanRT/YouTube.js/issues/577)) ([6082b4a](https://github.com/LuanRT/YouTube.js/commit/6082b4a52ee07a622735e6e9128a0531a5ae3bfb))
* **Format:** Add `max_dvr_duration_sec` and `target_duration_dec` ([#570](https://github.com/LuanRT/YouTube.js/issues/570)) ([586bb5f](https://github.com/LuanRT/YouTube.js/commit/586bb5f1398d68bfabfb9449f527e53c398c3767))
* **parser:** Add `ImageBannerView` ([#583](https://github.com/LuanRT/YouTube.js/issues/583)) ([2073aa9](https://github.com/LuanRT/YouTube.js/commit/2073aa910a0e441a8ec1a9ba0434051ec0e2e6a9))
* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580)) ([6dd03e1](https://github.com/LuanRT/YouTube.js/commit/6dd03e1658036c2fba0696de81033b5e16abb379))
* **VideoDetails:** Add `is_live_dvr_enabled`, `is_low_latency_live_stream` and `live_chunk_readahead` ([#569](https://github.com/LuanRT/YouTube.js/issues/569)) ([254f779](https://github.com/LuanRT/YouTube.js/commit/254f77944fcd398cc19cb62b82b0fdfbe6ed70ed))
* **VideoInfo:** Add live stream `end_timestamp` ([#571](https://github.com/LuanRT/YouTube.js/issues/571)) ([562e6a2](https://github.com/LuanRT/YouTube.js/commit/562e6a20f06ef5302af427861355215630d91edc))
### Bug Fixes
* **DecoratedAvatarView:** Fix parsing and optional properties ([#584](https://github.com/LuanRT/YouTube.js/issues/584)) ([fed3512](https://github.com/LuanRT/YouTube.js/commit/fed3512461277b7fc41e26c770e2bd3d4a7d5eb5))
* **PlayerCaptionTracklist:** Fix `captions_tracks[].kind` type ([#586](https://github.com/LuanRT/YouTube.js/issues/586)) ([7fbc37f](https://github.com/LuanRT/YouTube.js/commit/7fbc37f9d1c109e448085d5736326dce82ca2c9a))
* **proto:** Fix visitor data base64url decoding ([#576](https://github.com/LuanRT/YouTube.js/issues/576)) ([3980f97](https://github.com/LuanRT/YouTube.js/commit/3980f97b8fca05f95cda1ab347db9402c55b8b3c))
* **toDash:** Add missing transfer characteristics for h264 streams ([#573](https://github.com/LuanRT/YouTube.js/issues/573)) ([59f4cfb](https://github.com/LuanRT/YouTube.js/commit/59f4cfb4db6184d47f0a6634832055e9ce71f644))
## [8.2.0](https://github.com/LuanRT/YouTube.js/compare/v8.1.0...v8.2.0) (2024-01-08)

View File

@@ -10,82 +10,55 @@
[twitter]: https://twitter.com/thesciencephile
[discord]: https://discord.gg/syDu7Yks54
<h1 align=center>YouTube.js</h1>
<p align=center>A full-featured wrapper around the InnerTube API</p>
<div align="center">
<br/>
<p>
<a href="https://github.com/LuanRT/YouTube.js"><img src="https://luanrt.github.io/assets/img/ytjs.svg" title="youtube.js" alt="YouTube.js' Github Page" width="200" /></a>
</p>
<p align="center">A full-featured wrapper around the InnerTube API</p>
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions]
[![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
[![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]
<h5>
Sponsored by&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://serpapi.com"><img src="https://luanrt.github.io/assets/img/serpapi.svg" alt="SerpApi - API to get search engine results with ease." height=35 valign="middle"></a>
</h5>
<br>
[![Donate](https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)][collaborators]
</div>
<div align="center">
<p>
<sup>Special thanks to:</sup>
<br>
<br>
<a href="https://serpapi.com" target="_blank">
<img width="80" alt="SerpApi" src="https://luanrt.github.io/assets/img/serpapi.svg" />
<br>
<sub>
API to get search engine results with ease.
</sub>
</a>
</p>
</div>
<br>
<hr>
<br>
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.
## Table of Contents
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
### Table of Contents
<ol>
<li>
<a href="#description">Description</a>
</li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Prerequisites</a></li>
<li><a href="#installation">Installation</a></li>
</ul>
</li>
<li><a href="#prerequisites">Prerequisites</a></li>
<li><a href="#installation">Installation</a></li>
<li>
<a href="#usage">Usage</a>
<ul>
<li><a href="#browser-usage">Browser Usage</a></li>
<li><a href="#caching">Caching</a></li>
<li><a href="#api">API</a></li>
<li><a href="#extending-the-library">Extending the library</a></li>
</ul>
</li>
<li><a href="#extending-the-library">Extending the library</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#disclaimer">Disclaimer</a></li>
<li><a href="#license">License</a></li>
</ol>
## Description
InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.
If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).
## Getting Started
### Prerequisites
YouTube.js runs on Node.js, Deno, and modern browsers.
It requires a runtime with the following features:
- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
- On Node, we use [undici](https://github.com/nodejs/undici)'s fetch implementation, which requires Node.js 16.8+. If you need to use an older version, you may provide your own fetch implementation. See [providing your own fetch implementation](#custom-fetch) for more information.
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` returns a non-standard `Readable` object.)
- The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` return a non-standard `Readable` object.)
- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required.
### Installation
@@ -114,7 +87,7 @@ import { Innertube } from 'youtubei.js';
const youtube = await Innertube.create(/* options */);
```
### Initialization Options
### Options
<details>
<summary>Click to expand</summary>
@@ -124,19 +97,22 @@ const youtube = await Innertube.create(/* options */);
| `location` | `string` | Geolocation. | `US` |
| `account_index` | `number` | The account index to use. This is useful if you have multiple accounts logged in. **NOTE:** Only works if you are signed in with cookies. | `0` |
| `visitor_data` | `string` | Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in. A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint. | `undefined` |
| `po_token` | `string` | Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine client. Valid tokens can be generated using [BgUtils](https://github.com/LuanRT/BgUtils) or [Invidious' tool](https://github.com/iv-org/youtube-trusted-session-generator). | `undefined` |
| `retrieve_player` | `boolean` | Specifies whether to retrieve the JS player. Disabling this will make session creation faster. **NOTE:** Deciphering formats is not possible without the JS player. | `true` |
| `enable_safety_mode` | `boolean` | Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content. | `false` |
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. | `false` |
| `generate_session_locally` | `boolean` | Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. **NOTE:** If you are using the cache option and a session has already been generated, this will be ignored. If you want to force a new session to be generated, you must clear the cache or disable session caching. | `false` |
| `enable_session_cache` | `boolean` | Specifies whether to cache the session data. | `true` |
| `device_category` | `DeviceCategory` | Platform to use for the session. | `DESKTOP` |
| `client_type` | `ClientType` | InnerTube client type. | `WEB` |
| `client_type` | `ClientType` | InnerTube client type. It is not recommended to change this unless you know what you are doing. | `WEB` |
| `timezone` | `string` | The time zone. | `*` |
| `cache` | `ICache` | Used to cache the deciphering functions from the JS player. | `undefined` |
| `cache` | `ICache` | Used to cache algorithms, session data, and OAuth2 tokens. | `undefined` |
| `cookie` | `string` | YouTube cookies. | `undefined` |
| `fetch` | `FetchFunction` | Fetch function to use. | `fetch` |
</details>
## Browser Usage
### Browser Usage
To use YouTube.js in the browser, you must proxy requests through your own server. You can see our simple reference implementation in Deno at [`examples/browser/proxy/deno.ts`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/proxy/deno.ts).
You may provide your own fetch implementation to be used by YouTube.js, which we will use to modify and send the requests through a proxy. See [`examples/browser/web`](https://github.com/LuanRT/YouTube.js/tree/main/examples/browser/web) for a simple example using [Vite](https://vitejs.dev/).
@@ -195,7 +171,7 @@ A fully working example can be found in [`examples/browser/web`](https://github.
<a name="custom-fetch"></a>
## Providing your own fetch implementation
### Providing your own fetch implementation
You may provide your own fetch implementation to be used by YouTube.js. This can be useful in some cases to modify the requests before they are sent and transform the responses before they are returned (eg. for proxies).
```ts
// provide a fetch implementation
@@ -212,7 +188,7 @@ const yt = await Innertube.create({
<a name="caching"></a>
## Caching
### Caching
Caching the transformed player instance can greatly improve the performance. Our `UniversalCache` implementation uses different caching methods depending on the environment.
In Node.js, we use the `node:fs` module, `Deno.writeFile()` in Deno, and `indexedDB` in browsers.
@@ -237,7 +213,7 @@ const yt = await Innertube.create({
});
```
## API
### API
* `Innertube`
@@ -295,7 +271,7 @@ Retrieves video info.
| Param | Type | Description |
| --- | --- | --- |
| target | `string` \| `NavigationEndpoint` | If `string`, the id of the video. If `NavigationEndpoint`, the endpoint of watchable elements such as `Video`, `Mix` and `Playlist`. To clarify, valid endpoints have payloads containing at least `videoId` and optionally `playlistId`, `params` and `index`. |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC`, `YTMUSIC_ANDROID` or `TV_EMBEDDED` |
| client? | `InnerTubeClient` | InnerTube client to use. |
<details>
<summary>Methods & Getters</summary>
@@ -362,7 +338,7 @@ Suitable for cases where you only need basic video metadata. Also, it is faster
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The id of the video |
| client? | `InnerTubeClient` | `WEB`, `ANDROID`, `YTMUSIC_ANDROID`, `YTMUSIC`, `TV_EMBEDDED` |
| client? | `InnerTubeClient` | InnerTube client to use. |
<a name="search"></a>
### `search(query, filters?)`
@@ -697,7 +673,7 @@ Utility to call navigation endpoints.
| endpoint | `NavigationEndpoint` | The target endpoint |
| args? | `object` | Additional payload arguments |
## Extending the library
### Extending the library
YouTube.js is modular and easy to extend. Most of the methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.
@@ -712,7 +688,7 @@ import { Innertube } from 'youtubei.js';
const videoInfo = await yt.actions.execute('/player', {
// You can add any additional payloads here, and they'll merge with the default payload sent to InnerTube.
videoId,
client: 'YTMUSIC', // InnerTube client options: ANDROID, YTMUSIC, YTMUSIC_ANDROID, WEB, or TV_EMBEDDED.
client: 'YTMUSIC', // InnerTube client to use.
parse: true // tells YouTube.js to parse the response (not sent to InnerTube).
});
@@ -805,6 +781,6 @@ As such, any usage of trademarks to refer to such services is considered nominat
## License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
<p align=" right">
<p align="right">
(<a href="#top">back to top</a>)
</p>

View File

@@ -1,4 +1,4 @@
import glob from "glob";
import glob from 'glob';
import path from 'path';
import fs from 'fs';
import url from 'url';

111
eslint.config.js Normal file
View File

@@ -0,0 +1,111 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
ignores: [
"**/dist/",
"**/test/",
"**/cache/",
"**/bundle/",
"**/examples/",
"**/src/proto/generated/",
"**/*.{js,mjs,cjs}",
"**/*.d.ts",
"*.ts",
],
}, {
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.node,
...globals.browser,
}
},
rules: {
"max-len": ["error", {
code: 200,
ignoreComments: true,
ignoreTrailingComments: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true,
}], quotes: ["error", "single"],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error",
'no-sparse-arrays': 'off',
"no-template-curly-in-string": "error",
"no-unreachable-loop": "error",
"no-unused-private-class-members": "off",
"no-prototype-builtins": "off",
"no-async-promise-executor": "off",
"no-case-declarations": "off",
"no-return-assign": "off",
"no-floating-decimal": "error",
"no-implied-eval": "error",
"arrow-spacing": "error",
"no-invalid-this": "error",
"no-lone-blocks": "off",
"no-new-func": "off",
"no-new-wrappers": "error",
"no-new": "error",
"no-void": "error",
"no-octal-escape": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"no-unmodified-loop-condition": "error",
"no-useless-call": "error",
"no-useless-concat": "error",
"no-useless-escape": "error",
"no-useless-return": "error",
"no-else-return": "error",
"no-lonely-if": "error",
"no-undef-init": "error",
"no-unneeded-ternary": "error",
"no-var": "error",
"no-multi-spaces": "error",
"no-multiple-empty-lines": ["error", {
max: 1,
maxEOF: 0,
}],
"no-tabs": "error",
"brace-style": "error",
"new-parens": "error",
"space-infix-ops": "error",
"template-curly-spacing": "error",
"wrap-regex": "error",
"capitalized-comments": "error",
"prefer-template": "error",
"keyword-spacing": ["error", {
before: true,
}],
"object-curly-spacing": ["warn", "always"],
"array-bracket-spacing": ["error", "always"],
"arrow-parens": ["error", "always"],
"comma-dangle": ["error", "never"],
"comma-spacing": ["error", {
before: false,
after: true,
}],
"computed-property-spacing": ["error", "never"],
"func-call-spacing": ["error", "never"],
indent: ["error", 2, {
SwitchCase: 1,
}],
"key-spacing": ["error", {
beforeColon: false,
}],
semi: ["error", "always"],
"operator-assignment": ["error", "always"],
},
}
];

View File

@@ -8,22 +8,21 @@ Just like the official Data API, YouTube.js supports using your own OAuth2 crede
The library supports signing in using YouTube TV's client id. This is the recommended way to sign in as it doesn't require you to create your own OAuth2 credentials.
```js
// 'auth-pending' is fired with the info needed to sign in via OAuth.
// Fired when waiting for the user to authorize the sign in attempt.
yt.session.on('auth-pending', (data) => {
// data.verification_url contains the URL to visit to authenticate.
// data.user_code contains the code to enter on the website.
// data.verification_url contains the authorization URL.
// data.user_code contains the code to enter on the website.
});
// 'auth' is fired once the authentication is complete
// Fired when authentication is successful.
yt.session.on('auth', ({ credentials }) => {
// do something with the credentials, eg; save them in a database.
// Do something with the credentials, eg; save them in a database.
console.log('Sign in successful');
});
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
yt.session.on('update-credentials', ({ credentials }) => {
// do something with the updated credentials
});
// Fired when the access token expires.
yt.session.on('update-credentials', ({ credentials }) => { /** do something with the updated credentials. */ });
await yt.session.signIn(/* credentials */);
```
@@ -56,7 +55,7 @@ await yt.session.oauth.removeCache();
# Cookies
> **Note**
> This is not as reliable as OAuth2 as cookies expire and can be completely revoked at any time.
> This is not as reliable as OAuth2. Cookies can expire and are not very secure.
```js
const yt = await Innertube.create({

View File

@@ -111,9 +111,11 @@ app.get('/login', async (req, res) => {
await innertube.session.signIn({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires: new Date(tokens.expiry_date),
client_id: clientId,
client_secret: clientSecret,
expiry_date: new Date(tokens.expiry_date).toISOString(),
client: {
client_id: clientId,
client_secret: clientSecret
}
});
await innertube.session.oauth.cacheCredentials();

View File

@@ -6,17 +6,17 @@ import { Innertube, UniversalCache } from 'youtubei.js';
cache: new UniversalCache(false)
});
// 'auth-pending' is fired with the info needed to sign in via OAuth.
// Fired when waiting for the user to authorize the sign in attempt.
yt.session.on('auth-pending', (data) => {
console.log(`Go to ${data.verification_url} in your browser and enter code ${data.user_code} to authenticate.`);
});
// 'auth' is fired once the authentication is complete
// Fired when authentication is successful.
yt.session.on('auth', ({ credentials }) => {
console.log('Sign in successful:', credentials);
});
// 'update-credentials' is fired when the access token expires, if you do not save the updated credentials any subsequent request will fail
// Fired when the access token expires.
yt.session.on('update-credentials', async ({ credentials }) => {
console.log('Credentials updated:', credentials);
await yt.session.oauth.cacheCredentials();

View File

@@ -1,61 +1,19 @@
# Browser Usage Example
YouTube.js works in the browser!
## Usage
# Browser Usage
To use YouTube.js in the browser you must proxy requests through your own server. You can see our simple reference implementation in Deno in `examples/browser/proxy/deno.ts`.
We'll use our own fetch implementation to proxy requests through our server. This is a simple example, but you can use any fetch implementation you want.
## Example
**NOTE**: Build the library before running the examples.
```ts
import { Innertube } from "youtubei.js/web.bundle.min";
Web application:
const yt = await Innertube.create({
fetch: async (input, init) => {
// url
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// transform the url for use with our proxy
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
// now serialize the headers
url.searchParams.set('__headers', JSON.stringify([...headers]));
// copy over the request
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
headers.delete('user-agent');
// fetch the url
return fetch(request, init ? {
...init,
headers
} : {
headers
});
},
cache: new UniversalCache(),
});
```shell
cd examples/browser/web
npm install
npm run dev
```
After that, you can use the library as normal.
## Example
We've got a full example in `examples/browser/web` using vite.
Proxy:
```shell
deno run --allow-net --allow-read examples/browser/proxy/deno.ts
```

View File

@@ -10,7 +10,7 @@ function copyHeader(headerName: string, to: Headers, from: Headers) {
}
const handler = async (request: Request): Promise<Response> => {
// if options send do CORS preflight
// If options send do CORS preflight
if (request.method === 'OPTIONS') {
const response = new Response('', {
status: 200,
@@ -18,19 +18,19 @@ const handler = async (request: Request): Promise<Response> => {
'Access-Control-Allow-Origin': request.headers.get('origin') || '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers':
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-origin, x-youtube-client-version, Accept-Language, Range, Referer',
'Origin, X-Requested-With, Content-Type, Accept, Authorization, x-goog-visitor-id, x-goog-api-key, x-origin, x-youtube-client-version, x-youtube-client-name, x-goog-api-format-version, x-user-agent, Accept-Language, Range, Referer',
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Credentials': 'true',
}),
'Access-Control-Allow-Credentials': 'true'
})
});
return response;
}
const url = new URL(request.url, `http://localhost/`);
const url = new URL(request.url, 'http://localhost/');
if (!url.searchParams.has('__host')) {
return new Response(
'Request is formatted incorrectly. Please include __host in the query string.',
{ status: 400 },
{ status: 400 }
);
}
@@ -42,33 +42,36 @@ const handler = async (request: Request): Promise<Response> => {
// Copy headers from the request to the new request
const request_headers = new Headers(
JSON.parse(url.searchParams.get('__headers') || '{}'),
JSON.parse(url.searchParams.get('__headers') || '{}')
);
copyHeader('range', request_headers, request.headers);
!request_headers.has('user-agent') && copyHeader('user-agent', request_headers, request.headers);
if (!request_headers.has('user-agent'))
copyHeader('user-agent', request_headers, request.headers);
url.searchParams.delete('__headers');
// Make the request to YouTube
const fetchRes = await fetch(url, {
method: request.method,
headers: request_headers,
body: request.body,
body: request.body
});
// Construct the return headers
const headers = new Headers();
// copy content headers
// Copy content headers
copyHeader('content-length', headers, fetchRes.headers);
copyHeader('content-type', headers, fetchRes.headers);
copyHeader('content-disposition', headers, fetchRes.headers);
copyHeader('accept-ranges', headers, fetchRes.headers);
copyHeader('content-range', headers, fetchRes.headers);
// add cors headers
// Add cors headers
headers.set(
'Access-Control-Allow-Origin',
request.headers.get('origin') || '*',
request.headers.get('origin') || '*'
);
headers.set('Access-Control-Allow-Headers', '*');
headers.set('Access-Control-Allow-Methods', '*');
@@ -77,8 +80,8 @@ const handler = async (request: Request): Promise<Response> => {
// Return the proxied response
return new Response(fetchRes.body, {
status: fetchRes.status,
headers: headers,
headers: headers
});
};
await serve(handler, { port });
await serve(handler, { port });

298
examples/browser/web/package-lock.json generated Normal file
View File

@@ -0,0 +1,298 @@
{
"name": "web",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.0.0",
"dependencies": {
"bgutils-js": "^1.1.0",
"shaka-player": "^4.3.8"
},
"devDependencies": {
"typescript": "^4.6.4",
"vite": "^3.2.10"
}
},
"node_modules/bgutils-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-1.1.0.tgz",
"integrity": "sha512-+v+MWO02VAfSKuuh9gpjxBTllFGkIiqzZT7ELwScOtm2UWk6MOm7lqkVtzctcjCrG0sgRZccfEbgaEWHozXLSQ==",
"funding": [
"https://github.com/sponsors/LuanRT"
]
},
"node_modules/eme-encryption-scheme-polyfill": {
"version": "2.1.1",
"license": "Apache-2.0"
},
"node_modules/esbuild": {
"version": "0.15.18",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.15.18",
"@esbuild/linux-loong64": "0.15.18",
"esbuild-android-64": "0.15.18",
"esbuild-android-arm64": "0.15.18",
"esbuild-darwin-64": "0.15.18",
"esbuild-darwin-arm64": "0.15.18",
"esbuild-freebsd-64": "0.15.18",
"esbuild-freebsd-arm64": "0.15.18",
"esbuild-linux-32": "0.15.18",
"esbuild-linux-64": "0.15.18",
"esbuild-linux-arm": "0.15.18",
"esbuild-linux-arm64": "0.15.18",
"esbuild-linux-mips64le": "0.15.18",
"esbuild-linux-ppc64le": "0.15.18",
"esbuild-linux-riscv64": "0.15.18",
"esbuild-linux-s390x": "0.15.18",
"esbuild-netbsd-64": "0.15.18",
"esbuild-openbsd-64": "0.15.18",
"esbuild-sunos-64": "0.15.18",
"esbuild-windows-32": "0.15.18",
"esbuild-windows-64": "0.15.18",
"esbuild-windows-arm64": "0.15.18"
}
},
"node_modules/esbuild-windows-64": {
"version": "0.15.18",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.13.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.0.0",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.33",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/resolve": {
"version": "1.22.8",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "2.79.1",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/shaka-player": {
"version": "4.7.7",
"license": "Apache-2.0",
"dependencies": {
"eme-encryption-scheme-polyfill": "^2.1.1"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/vite": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.10.tgz",
"integrity": "sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==",
"dev": true,
"dependencies": {
"esbuild": "^0.15.9",
"postcss": "^8.4.18",
"resolve": "^1.22.1",
"rollup": "^2.79.1"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

View File

@@ -10,9 +10,10 @@
},
"devDependencies": {
"typescript": "^4.6.4",
"vite": "^3.0.0"
"vite": "^3.2.10"
},
"dependencies": {
"bgutils-js": "^1.1.0",
"shaka-player": "^4.3.8"
}
}
}

View File

@@ -1,8 +1,8 @@
import { Innertube, UniversalCache } from '../../../../bundle/browser';
import { Innertube, Proto, UniversalCache, Utils } from '../../../../bundle/browser';
import BG from 'bgutils-js';
// @ts-ignore - Shaka's TS support is not the best.
import shaka from 'shaka-player/dist/shaka-player.ui.js';
import "shaka-player/dist/controls.css";
const title = document.getElementById('title') as HTMLHeadingElement;
@@ -11,51 +11,17 @@ const metadata = document.getElementById('metadata') as HTMLDivElement;
const loader = document.getElementById('loader') as HTMLDivElement;
const form = document.querySelector('form') as HTMLFormElement;
async function main() {
const visitorData = Proto.encodeVisitorData(Utils.generateRandomString(11), Math.floor(Date.now() / 1000));
const poToken = await getPo(visitorData);
const yt = await Innertube.create({
po_token: poToken,
visitor_data: visitorData,
generate_session_locally: true,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// Transform the url for use with our proxy.
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
// Now serialize the headers.
url.searchParams.set('__headers', JSON.stringify([...headers]));
if (input instanceof Request) {
// @ts-ignore
input.duplex = 'half';
}
// Copy over the request.
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
headers.delete('user-agent');
return fetch(request, init ? {
...init,
headers
} : {
headers
});
},
cache: new UniversalCache(false),
fetch: fetchFn,
cache: new UniversalCache(true),
});
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
@@ -141,7 +107,8 @@ async function main() {
if (shaka.Player.isBrowserSupported()) {
videoEl.poster = info.basic_info.thumbnail![0].url;
player = new shaka.Player(videoEl);
player = new shaka.Player();
await player.attach(videoEl);
ui = new shaka.ui.Overlay(player, shakaContainer, videoEl);
const config = {
@@ -194,6 +161,7 @@ async function main() {
request.headers = {};
url.searchParams.set("range", headers.Range.split("=")[1]);
url.searchParams.set("alr", "yes");
delete headers.Range;
}
}
@@ -207,14 +175,12 @@ async function main() {
player.getNetworkingEngine()?.registerResponseFilter(async (type: any, response: any) => {
const dataView = new DataView(response.data);
if (response.data.byteLength < 4 ||
dataView.getUint32(0) != HTTP_IN_HEX) {
// This doesn't start with "http", so it is not an ALR.
return;
}
// Interpret the response data as a URL string.
const response_as_string = shaka.util.StringUtils.fromUTF8(response.data);
let retry_parameters;
@@ -258,6 +224,80 @@ async function main() {
});
}
async function getPo(identity: string): Promise<string | undefined> {
const requestKey = 'O43z0dpjhgX20SCx4KAo';
const bgConfig = {
fetch: fetchFn,
globalObj: window,
requestKey,
identity
};
const challenge = await BG.Challenge.create(bgConfig);
if (!challenge)
throw new Error('Could not get challenge');
if (challenge.script) {
const script = challenge.script.find((sc) => sc !== null);
if (script)
new Function(script)();
} else {
console.warn('Unable to load VM.');
}
const poToken = await BG.PoToken.generate({
program: challenge.challenge,
globalName: challenge.globalName,
bgConfig
});
return poToken;
}
function fetchFn(input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// Transform the url for use with our proxy.
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
const headers = init?.headers
? new Headers(init.headers)
: input instanceof Request
? input.headers
: new Headers();
// Now serialize the headers.
url.searchParams.set('__headers', JSON.stringify([...headers]));
if (input instanceof Request) {
// @ts-expect-error - x
input.duplex = 'half';
}
// Copy over the request.
const request = new Request(
url,
input instanceof Request ? input : undefined
);
headers.delete('user-agent');
return fetch(request, init ? {
...init,
headers
} : {
headers
});
}
function showUI(args: { hidePlayer?: boolean } = {
hidePlayer: true,
}) {

View File

@@ -0,0 +1,18 @@
{
"name": "cf-worker",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240208.0",
"typescript": "^5.0.4",
"wrangler": "^3.0.0"
},
"dependencies": {
"youtubei.js": "latest"
}
}

View File

@@ -0,0 +1,19 @@
import { Innertube } from "youtubei.js/cf-worker";
export interface Env {}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
// cannot initialize Innertube in global scope as it makes fetch requests
const yt = await Innertube.create();
const video = await yt.getInfo("jNQXAC9IVRw");
console.log("Video title is", video.basic_info.title);
return new Response("Hello World!");
},
};

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"jsx": "react",
"module": "es2022",
"moduleResolution": "node",
"types": ["@cloudflare/workers-types/2023-07-01"],
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

View File

@@ -0,0 +1,3 @@
name = "cf-worker-youtubei"
main = "src/index.ts"
compatibility_date = "2024-02-08"

View File

@@ -5,8 +5,8 @@ A `CommentThread` represents a top-level comment and its replies.
## API
* CommentThread
* [.comment](#comment) ⇒ `Comment`
* [.replies](#replies) ⇒ `Comment[]`
* [.comment](#comment) ⇒ `Comment | CommentView`
* [.replies](#replies) ⇒ `(Comment | CommentView)[]`
* [.getReplies](#getreplies) ⇒ `function`
* [.getContinuation](#getcontinuation) ⇒ `function`
* [.has_continuation](#hascontinuation) ⇒ `boolean`
@@ -14,7 +14,7 @@ A `CommentThread` represents a top-level comment and its replies.
<a name="comment"></a>
### comment
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
The top-level comment. **Note:** More about the `Comment` node [here](./Comment.md) (OUTDATED! `Comment` has been replaced by [`CommentView`](./CommentView.md) nodes).
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
@@ -22,7 +22,7 @@ The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
### replies
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
**Type:** [`(Comment | CommentView)[]`](../../src/parser/classes/comments/Comment.ts)
<a name="getreplies"></a>
### getReplies()

View File

@@ -0,0 +1,48 @@
## CommentView
Contains information about a single comment. A [`CommentView`](../../src/parser/classes/comments/CommentView.ts) can be a top-level comment or a reply to a top-level comment.
## API
* Comment
* [.like](#like) ⇒ `function`
* [.unlike](#like) ⇒ `function`
* [.dislike](#dislike) ⇒ `function`
* [.undislike](#dislike) ⇒ `function`
* [.reply](#reply) ⇒ `function`
* [.translate](#translate) ⇒ `function`
<a name="like"></a>
### like()
Likes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="unlike"></a>
### unlike()
Unlikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="dislike"></a>
### dislike()
Dislikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="undislike"></a>
### undislike()
Undislikes the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="reply"></a>
### reply(comment_text: string)
Replies to the comment.
**Returns:** `Promise.<ApiResponse>`
<a name="translate"></a>
### translate(target_language: string)
Translates the comment to the given language.
**Returns:** `Promise.<ApiResponse & { content?: string }>`

View File

@@ -59,7 +59,4 @@ Returns whether there are more comments to be fetched.
### page
Returns original InnerTube response (sanitized).
**Returns:** `ParsedResponse`
## Example
See [`index.ts`]('./index.ts').
**Returns:** `ParsedResponse`

View File

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

View File

@@ -14,13 +14,14 @@ import { existsSync, mkdirSync, createWriteStream } from 'fs';
if (!album.contents)
throw new Error('Album appears to be empty');
console.info(`Album "${album.header?.title.toString()}" by ${album.header?.author?.name}`, '\n');
console.info(`Album "${album.header?.title.toString()}"`, '\n');
for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'mp4' // media container format
format: 'mp4', // media container format,
client: 'YTMUSIC'
});
console.info(`Downloading ${song.title} (${song.id})`);
@@ -40,5 +41,5 @@ import { existsSync, mkdirSync, createWriteStream } from 'fs';
console.info(`${song.id} - Done!`, '\n');
}
console.info(`Downloaded ${album.header?.song_count}!`);
console.info(`Done!`);
})();

View File

@@ -109,4 +109,4 @@ Sends a message.
**Returns:** `Promise<ObservedArray<AddChatItemAction>>`
## Example
See [`index.ts`]('./index.ts').
See [`index.ts`](./index.ts).

View File

@@ -1,15 +1,14 @@
import { Innertube, UniversalCache, YTNodes, LiveChatContinuation } from 'youtubei.js';
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
const yt = await Innertube.create({ cache: new UniversalCache(false) });
const search = await yt.search('lofi hip hop radio - beats to relax/study to');
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
const livechat = info.getLiveChat();
livechat.on('start', (initial_data: LiveChatContinuation) => {
livechat.on('start', (initial_data) => {
/**
* Initial info is what you see when you first open a a live chat — this is; initial actions (pinned messages, top donations..), account's info and so forth.
*/
@@ -32,7 +31,7 @@ import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/Li
livechat.on('end', () => console.info('This live stream has ended.'));
livechat.on('chat-update', (action: ChatAction) => {
livechat.on('chat-update', (action) => {
/**
* An action represents what is being added to
* the live chat. All actions have a `type` property,
@@ -94,7 +93,7 @@ import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/Li
}
});
livechat.on('metadata-update', (metadata: LiveMetadata) => {
livechat.on('metadata-update', (metadata) => {
console.info(`
VIEWS: ${metadata.views?.view_count.toString()}
LIKES: ${metadata.likes?.default_text}

View File

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

8529
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "8.2.0",
"version": "10.4.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -12,11 +12,17 @@
"web": [
"./dist/src/platform/lib.d.ts"
],
"react-native": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle": [
"./dist/src/platform/lib.d.ts"
],
"web.bundle.min": [
"./dist/src/platform/lib.d.ts"
],
"cf-worker": [
"./dist/src/platform/lib.d.ts"
]
}
},
@@ -29,6 +35,7 @@
"deno": "./dist/src/platform/deno.js",
"types": "./dist/src/platform/lib.d.ts",
"browser": "./dist/src/platform/web.js",
"react-native": "./dist/src/platform/react-native.js",
"default": "./dist/src/platform/web.js"
},
"./agnostic": {
@@ -39,6 +46,10 @@
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/web.js"
},
"./react-native": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/react-native.js"
},
"./web.bundle": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.js"
@@ -46,6 +57,10 @@
"./web.bundle.min": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.min.js"
},
"./cf-worker": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/cf-worker.js"
}
},
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
@@ -59,23 +74,21 @@
"akkadaska (https://github.com/akkadaska)",
"Absidue (https://github.com/absidue)"
],
"directories": {
"test": "./test",
"examples": "./examples",
"dist": "./dist"
},
"scripts": {
"test": "npx jest --verbose",
"lint": "npx eslint ./src",
"lint:fix": "npx eslint --fix ./src",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod",
"build:parser-map": "node ./scripts/gen-parser-map.mjs",
"clean": "npx rimraf ./dist/src ./dist/package.json ./bundle/browser.js ./bundle/browser.js.map ./bundle/browser.min.js ./bundle/browser.min.js.map ./bundle/node.cjs ./bundle/node.cjs.map ./bundle/cf-worker.js ./bundle/cf-worker.js.map ./bundle/react-native.js ./bundle/react-native.js.map ./deno",
"build": "npm run clean && npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker && npm run bundle:react-native",
"build:parser-map": "node ./dev-scripts/gen-parser-map.mjs",
"build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
"build:esm": "npx tspc",
"build:deno": "npx cpy ./src ./deno && npx esbuild ./src/utils/DashManifest.tsx --keep-names --format=esm --platform=neutral --target=es2020 --outfile=./deno/src/utils/DashManifest.js && npx cpy ./package.json ./deno && npx replace \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.ts';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r",
"bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --external:tslib --sourcemap --banner:js=\"/* eslint-disable */\"",
"bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/browser.js --platform=browser",
"bundle:react-native": "npx esbuild ./dist/src/platform/react-native.js --bundle --target=es2020 --keep-names --format=esm --platform=neutral --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/react-native.js",
"bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
"bundle:cf-worker": "npx esbuild ./dist/src/platform/cf-worker.js --banner:js=\"/* eslint-disable */\" --bundle --target=es2020 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/cf-worker.js --platform=node",
"prepare": "npm run build",
"watch": "npx tsc --watch"
},
@@ -85,7 +98,7 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.1.0",
"jintr": "^2.1.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},
@@ -93,23 +106,23 @@
"typescript": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/glob": "^8.1.0",
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"cpy-cli": "^4.2.0",
"esbuild": "^0.14.49",
"eslint": "^8.19.0",
"eslint-plugin-tsdoc": "^0.2.16",
"eslint": "^9.9.0",
"glob": "^8.0.3",
"jest": "^28.1.3",
"globals": "^15.9.0",
"jest": "^29.7.0",
"pbkit": "^0.0.59",
"replace": "^1.2.2",
"ts-jest": "^28.0.8",
"ts-jest": "^29.1.4",
"ts-patch": "^3.0.2",
"ts-transformer-inline-file": "^0.2.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"typescript-eslint": "^8.2.0"
},
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"

View File

@@ -1,29 +1,8 @@
import type { SessionOptions } from './core/Session.js';
import Session from './core/Session.js';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';
import type Format from './parser/classes/misc/Format.js';
import Channel from './parser/youtube/Channel.js';
import Comments from './parser/youtube/Comments.js';
import Guide from './parser/youtube/Guide.js';
import HashtagFeed from './parser/youtube/HashtagFeed.js';
import History from './parser/youtube/History.js';
import HomeFeed from './parser/youtube/HomeFeed.js';
import Library from './parser/youtube/Library.js';
import NotificationsMenu from './parser/youtube/NotificationsMenu.js';
import Playlist from './parser/youtube/Playlist.js';
import Search from './parser/youtube/Search.js';
import VideoInfo from './parser/youtube/VideoInfo.js';
import ShortsVideoInfo from './parser/ytshorts/VideoInfo.js';
import { Kids, Music, Studio } from './core/clients/index.js';
import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js';
import { Feed, TabbedFeed } from './core/mixins/index.js';
import * as Proto from './proto/index.js';
import * as Constants from './utils/Constants.js';
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js';
import {
BrowseEndpoint,
GetNotificationMenuEndpoint,
@@ -32,28 +11,37 @@ import {
PlayerEndpoint,
ResolveURLEndpoint,
SearchEndpoint,
Reel
Reel,
Notification
} from './core/endpoints/index.js';
import { GetUnseenCountEndpoint } from './core/endpoints/notification/index.js';
import {
Channel,
Comments,
Guide,
HashtagFeed,
History,
HomeFeed,
Library,
NotificationsMenu,
Playlist,
Search,
VideoInfo
} from './parser/youtube/index.js';
import { ShortFormVideoInfo } from './parser/ytshorts/index.js';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';
import * as Proto from './proto/index.js';
import * as Constants from './utils/Constants.js';
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js';
import type { ApiResponse } from './core/Actions.js';
import { type IBrowseResponse, type IParsedResponse } from './parser/types/index.js';
import type { INextRequest } from './types/index.js';
import type { InnerTubeConfig, InnerTubeClient, SearchFilters, INextRequest } from './types/index.js';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js';
import type { DownloadOptions, FormatOptions } from './types/FormatUtils.js';
import { encodeReelSequence } from './proto/index.js';
export type InnertubeConfig = SessionOptions;
export type InnerTubeClient = 'WEB' | 'iOS' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS';
export type SearchFilters = Partial<{
upload_date: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
type: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
duration: 'all' | 'short' | 'medium' | 'long';
sort_by: 'relevance' | 'rating' | 'upload_date' | 'view_count';
features: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
}>;
import type Format from './parser/classes/misc/Format.js';
/**
* Provides access to various services and modules in the YouTube API.
@@ -65,17 +53,12 @@ export default class Innertube {
this.#session = session;
}
static async create(config: InnertubeConfig = {}): Promise<Innertube> {
static async create(config: InnerTubeConfig = {}): Promise<Innertube> {
return new Innertube(await Session.create(config));
}
/**
* Retrieves video info.
* @param target - The video id or `NavigationEndpoint`.
* @param client - The client to use.
*/
async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ target });
throwIfMissing({ target: target });
let next_payload: INextRequest;
@@ -91,7 +74,7 @@ export default class Innertube {
video_id: target
});
} else {
throw new InnertubeError('Invalid target, expected either a video id or a valid NavigationEndpoint', target);
throw new InnertubeError('Invalid target. Expected a video id or NavigationEndpoint.', target);
}
if (!next_payload.videoId)
@@ -101,7 +84,8 @@ export default class Innertube {
video_id: next_payload.videoId,
playlist_id: next_payload?.playlistId,
client: client,
sts: this.#session.player?.sts
sts: this.#session.player?.sts,
po_token: this.#session.po_token
});
const player_response = this.actions.execute(PlayerEndpoint.PATH, player_payload);
@@ -113,11 +97,6 @@ export default class Innertube {
return new VideoInfo(response, this.actions, cpn);
}
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
throwIfMissing({ video_id });
@@ -125,7 +104,8 @@ export default class Innertube {
PlayerEndpoint.PATH, PlayerEndpoint.build({
video_id: video_id,
client: client,
sts: this.#session.player?.sts
sts: this.#session.player?.sts,
po_token: this.#session.po_token
})
);
@@ -134,37 +114,26 @@ export default class Innertube {
return new VideoInfo([ response ], this.actions, cpn);
}
/**
* Retrieves shorts info.
* @param short_id - The short id.
* @param client - The client to use.
*/
async getShortsWatchItem(short_id: string, client?: InnerTubeClient): Promise<ShortsVideoInfo> {
throwIfMissing({ short_id });
async getShortsVideoInfo(video_id: string, client?: InnerTubeClient): Promise<ShortFormVideoInfo> {
throwIfMissing({ video_id });
const watchResponse = this.actions.execute(
Reel.WatchEndpoint.PATH, Reel.WatchEndpoint.build({
short_id: short_id,
client: client
const watch_response = this.actions.execute(
Reel.ReelItemWatchEndpoint.PATH, Reel.ReelItemWatchEndpoint.build({ video_id, client })
);
const sequence_response = this.actions.execute(
Reel.ReelWatchSequenceEndpoint.PATH, Reel.ReelWatchSequenceEndpoint.build({
sequence_params: Proto.encodeReelSequence(video_id)
})
);
const sequenceResponse = this.actions.execute(
Reel.WatchSequenceEndpoint.PATH, Reel.WatchSequenceEndpoint.build({
sequenceParams: encodeReelSequence(short_id)
})
);
const response = await Promise.all([ watch_response, sequence_response ]);
const response = await Promise.all([ watchResponse, sequenceResponse ]);
const cpn = generateRandomString(16);
return new ShortsVideoInfo(response, this.actions);
return new ShortFormVideoInfo([ response[0] ], this.actions, cpn, response[1]);
}
/**
* Searches a given query.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
@@ -177,10 +146,6 @@ export default class Innertube {
return new Search(this.actions, response);
}
/**
* Retrieves search suggestions for a given query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
@@ -202,11 +167,6 @@ export default class Innertube {
return suggestions;
}
/**
* Retrieves comments for a video.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
throwIfMissing({ video_id });
@@ -221,9 +181,6 @@ export default class Innertube {
return new Comments(this.actions, response.data);
}
/**
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEwhat_to_watch' })
@@ -239,9 +196,6 @@ export default class Innertube {
return new Guide(response.data);
}
/**
* Returns the account's library.
*/
async getLibrary(): Promise<Library> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FElibrary' })
@@ -249,10 +203,6 @@ export default class Innertube {
return new Library(this.actions, response);
}
/**
* Retrieves watch history.
* Which can also be achieved with {@link getLibrary}.
*/
async getHistory(): Promise<History> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEhistory' })
@@ -260,9 +210,6 @@ export default class Innertube {
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEtrending' }), parse: true }
@@ -270,9 +217,6 @@ export default class Innertube {
return new TabbedFeed(this.actions, response);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEsubscriptions' }), parse: true }
@@ -280,9 +224,6 @@ export default class Innertube {
return new Feed(this.actions, response);
}
/**
* Retrieves channels feed.
*/
async getChannelsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEchannels' }), parse: true }
@@ -290,10 +231,6 @@ export default class Innertube {
return new Feed(this.actions, response);
}
/**
* Retrieves contents for a given channel.
* @param id - Channel id
*/
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.execute(
@@ -302,9 +239,6 @@ export default class Innertube {
return new Channel(this.actions, response);
}
/**
* Retrieves notifications.
*/
async getNotifications(): Promise<NotificationsMenu> {
const response = await this.actions.execute(
GetNotificationMenuEndpoint.PATH, GetNotificationMenuEndpoint.build({
@@ -314,19 +248,22 @@ export default class Innertube {
return new NotificationsMenu(this.actions, response);
}
/**
* Retrieves unseen notifications count.
*/
async getUnseenNotificationsCount(): Promise<number> {
const response = await this.actions.execute(GetUnseenCountEndpoint.PATH);
// TODO: properly parse this
const response = await this.actions.execute(Notification.GetUnseenCountEndpoint.PATH);
// FIXME: properly parse this.
return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
}
/**
* Retrieves playlist contents.
* @param id - Playlist id
* Retrieves the user's playlists.
*/
async getPlaylists(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute(
BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEplaylist_aggregation' }), parse: true }
);
return new Feed(this.actions, response);
}
async getPlaylist(id: string): Promise<Playlist> {
throwIfMissing({ id });
@@ -341,10 +278,6 @@ export default class Innertube {
return new Playlist(this.actions, response);
}
/**
* Retrieves a given hashtag's page.
* @param hashtag - The hashtag to fetch.
*/
async getHashtag(hashtag: string): Promise<HashtagFeed> {
throwIfMissing({ hashtag });
@@ -368,11 +301,15 @@ export default class Innertube {
*/
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
const info = await this.getBasicInfo(video_id);
return info.chooseFormat(options);
const format = info.chooseFormat(options);
format.url = format.decipher(this.#session.player);
return format;
}
/**
* Downloads a given video. If you only need the direct download link see {@link getStreamingData}.
* Downloads a given video. If all you need the direct download link, see {@link getStreamingData}.
* If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
* @param video_id - The video id.
* @param options - Download options.
@@ -390,6 +327,10 @@ export default class Innertube {
const response = await this.actions.execute(
ResolveURLEndpoint.PATH, { ...ResolveURLEndpoint.build({ url }), parse: true }
);
if (!response.endpoint)
throw new InnertubeError('Failed to resolve URL. Expected a NavigationEndpoint but got undefined', response);
return response.endpoint;
}

View File

@@ -1,7 +1,7 @@
import { Parser, NavigateAction } from '../parser/index.js';
import { InnertubeError } from '../utils/Utils.js';
import type Session from './Session.js';
import type { Session } from './index.js';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
@@ -29,14 +29,10 @@ export type ParsedResponse<T> =
IParsedResponse;
export default class Actions {
#session: Session;
session: Session;
constructor(session: Session) {
this.#session = session;
}
get session(): Session {
return this.#session;
this.session = session;
}
/**
@@ -69,7 +65,7 @@ export default class Actions {
s_url.searchParams.set(key, params[key]);
}
const response = await this.#session.http.fetch(s_url);
const response = await this.session.http.fetch(s_url);
return response;
}
@@ -88,7 +84,7 @@ export default class Actions {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
if (this.#needsLogin(data.browseId) && !this.#session.logged_in)
if (this.#needsLogin(data.browseId) && !this.session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
}
@@ -131,7 +127,7 @@ export default class Actions {
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
const response = await this.#session.http.fetch(target_endpoint, {
const response = await this.session.http.fetch(target_endpoint, {
method: 'POST',
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
@@ -168,6 +164,7 @@ export default class Actions {
'FEhistory',
'FEsubscriptions',
'FEchannels',
'FEplaylist_aggregation',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_overview',

View File

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

338
src/core/OAuth2.ts Normal file
View File

@@ -0,0 +1,338 @@
import { OAuth2Error, Platform } from '../utils/Utils.js';
import { Log, Constants } from '../utils/index.js';
import type Session from './Session.js';
const TAG = 'OAuth2';
export type OAuth2ClientID = {
client_id: string;
client_secret: string;
};
export type OAuth2Tokens = {
access_token: string;
expiry_date: string;
expires_in?: number;
refresh_token: string;
scope?: string;
token_type?: string;
client?: OAuth2ClientID;
};
export type DeviceAndUserCode = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
error_code?: string;
};
export type OAuth2AuthEventHandler = (data: { credentials: OAuth2Tokens; }) => void;
export type OAuth2AuthPendingEventHandler = (data: DeviceAndUserCode) => void;
export type OAuth2AuthErrorEventHandler = (err: OAuth2Error) => void;
export default class OAuth2 {
#session: Session;
YTTV_URL: URL;
AUTH_SERVER_CODE_URL: URL;
AUTH_SERVER_TOKEN_URL: URL;
AUTH_SERVER_REVOKE_TOKEN_URL: URL;
client_id: OAuth2ClientID | undefined;
oauth2_tokens: OAuth2Tokens | undefined;
constructor(session: Session) {
this.#session = session;
this.YTTV_URL = new URL('/tv', Constants.URLS.YT_BASE);
this.AUTH_SERVER_CODE_URL = new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE);
this.AUTH_SERVER_TOKEN_URL = new URL('/o/oauth2/token', Constants.URLS.YT_BASE);
this.AUTH_SERVER_REVOKE_TOKEN_URL = new URL('/o/oauth2/revoke', Constants.URLS.YT_BASE);
}
async init(tokens?: OAuth2Tokens): Promise<void> {
if (tokens) {
this.setTokens(tokens);
if (this.shouldRefreshToken()) {
await this.refreshAccessToken();
}
this.#session.emit('auth', { credentials: this.oauth2_tokens });
return;
}
const loaded_from_cache = await this.#loadFromCache();
if (loaded_from_cache) {
Log.info(TAG, 'Loaded OAuth2 tokens from cache.', this.oauth2_tokens);
return;
}
if (!this.client_id)
this.client_id = await this.getClientID();
// Initialize OAuth2 flow
const device_and_user_code = await this.getDeviceAndUserCode();
this.#session.emit('auth-pending', device_and_user_code);
this.pollForAccessToken(device_and_user_code);
}
setTokens(tokens: OAuth2Tokens): void {
const tokensMod = tokens;
// Convert access token remaining lifetime to ISO string
if (tokensMod.expires_in) {
tokensMod.expiry_date = new Date(Date.now() + tokensMod.expires_in * 1000).toISOString();
delete tokensMod.expires_in; // We don't need this anymore
}
if (!this.validateTokens(tokensMod))
throw new OAuth2Error('Invalid tokens provided.');
this.oauth2_tokens = tokensMod;
if (tokensMod.client) {
Log.info(TAG, 'Using provided client id and secret.');
this.client_id = tokensMod.client;
}
}
async cacheCredentials(): Promise<void> {
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(this.oauth2_tokens));
await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
}
async #loadFromCache(): Promise<boolean> {
const data = await this.#session.cache?.get('youtubei_oauth_credentials');
if (!data)
return false;
const decoder = new TextDecoder();
const credentials = JSON.parse(decoder.decode(data));
this.setTokens(credentials);
this.#session.emit('auth', { credentials });
return true;
}
async removeCache(): Promise<void> {
await this.#session.cache?.remove('youtubei_oauth_credentials');
}
async pollForAccessToken(device_and_user_code: DeviceAndUserCode): Promise<void> {
if (!this.client_id)
throw new OAuth2Error('Client ID is missing.');
const { device_code, interval } = device_and_user_code;
const { client_id, client_secret } = this.client_id;
const payload = {
client_id,
client_secret,
code: device_code,
grant_type: 'http://oauth.net/grant_type/device/1.0'
};
const connInterval = setInterval(async () => {
const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
body: JSON.stringify(payload),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const response_data = await response.json();
if (response_data.error) {
switch (response_data.error) {
case 'access_denied':
this.#session.emit('auth-error', new OAuth2Error('Access was denied.', response_data));
clearInterval(connInterval);
break;
case 'expired_token':
this.#session.emit('auth-error', new OAuth2Error('The device code has expired.', response_data));
clearInterval(connInterval);
break;
case 'authorization_pending':
case 'slow_down':
Log.info(TAG, 'Polling for access token...');
break;
default:
this.#session.emit('auth-error', new OAuth2Error('Server returned an unexpected error.', response_data));
clearInterval(connInterval);
break;
}
return;
}
this.setTokens(response_data);
this.#session.emit('auth', { credentials: this.oauth2_tokens });
clearInterval(connInterval);
}, interval * 1000);
}
async revokeCredentials(): Promise<Response | undefined> {
if (!this.oauth2_tokens)
throw new OAuth2Error('Access token not found');
await this.removeCache();
const url = this.AUTH_SERVER_REVOKE_TOKEN_URL;
url.searchParams.set('token', this.oauth2_tokens.access_token);
return this.#session.http.fetch_function(url, { method: 'POST' });
}
async refreshAccessToken(): Promise<void> {
if (!this.client_id)
this.client_id = await this.getClientID();
if (!this.oauth2_tokens)
throw new OAuth2Error('No tokens available to refresh.');
const { client_id, client_secret } = this.client_id;
const { refresh_token } = this.oauth2_tokens;
const payload = {
client_id,
client_secret,
refresh_token,
grant_type: 'refresh_token'
};
const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
body: JSON.stringify(payload),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok)
throw new OAuth2Error(`Failed to refresh access token: ${response.status}`);
const response_data = await response.json();
if (response_data.error_code)
throw new OAuth2Error('Authorization server returned an error', response_data);
this.oauth2_tokens.access_token = response_data.access_token;
this.oauth2_tokens.expiry_date = new Date(Date.now() + response_data.expires_in * 1000).toISOString();
this.#session.emit('update-credentials', { credentials: this.oauth2_tokens });
}
async getDeviceAndUserCode(): Promise<DeviceAndUserCode> {
if (!this.client_id)
throw new OAuth2Error('Client ID is missing.');
const { client_id } = this.client_id;
const payload = {
client_id,
scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
device_id: Platform.shim.uuidv4(),
device_model: 'ytlr::'
};
const response = await this.#http.fetch_function(this.AUTH_SERVER_CODE_URL, {
body: JSON.stringify(payload),
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok)
throw new OAuth2Error(`Failed to get device/user code: ${response.status}`);
const response_data = await response.json();
if (response_data.error_code)
throw new OAuth2Error('Authorization server returned an error', response_data);
return response_data;
}
async getClientID(): Promise<OAuth2ClientID> {
const yttv_response = await this.#http.fetch_function(this.YTTV_URL, {
headers: {
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
'Referer': 'https://www.youtube.com/tv',
'Accept-Language': 'en-US'
}
});
if (!yttv_response.ok)
throw new OAuth2Error(`Failed to get client ID: ${yttv_response.status}`);
const yttv_response_data = await yttv_response.text();
let script_url_body: RegExpExecArray | null;
if ((script_url_body = Constants.OAUTH.REGEX.TV_SCRIPT.exec(yttv_response_data)) !== null) {
Log.info(TAG, `Got YouTubeTV script URL (${script_url_body[1]})`);
const script_response = await this.#http.fetch(script_url_body[1], { baseURL: Constants.URLS.YT_BASE });
if (!script_response.ok)
throw new OAuth2Error(`TV script request failed with status code ${script_response.status}`);
const script_response_data = await script_response.text();
const client_identity = script_response_data
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
if (!client_identity || !client_identity.groups)
throw new OAuth2Error('Could not obtain client ID.');
const { client_id, client_secret } = client_identity.groups;
Log.info(TAG, `Client identity retrieved (clientId=${client_id}, clientSecret=${client_secret}).`);
return {
client_id,
client_secret
};
}
throw new OAuth2Error('Could not obtain script URL.');
}
shouldRefreshToken(): boolean {
if (!this.oauth2_tokens)
return false;
return Date.now() > new Date(this.oauth2_tokens.expiry_date).getTime();
}
validateTokens(tokens: OAuth2Tokens): boolean {
const propertiesAreValid = (
Boolean(tokens.access_token) &&
Boolean(tokens.expiry_date) &&
Boolean(tokens.refresh_token)
);
const typesAreValid = (
typeof tokens.access_token === 'string' &&
typeof tokens.expiry_date === 'string' &&
typeof tokens.refresh_token === 'string'
);
return typesAreValid && propertiesAreValid;
}
get #http() {
return this.#session.http;
}
}

View File

@@ -1,29 +1,27 @@
import { Platform, getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils.js';
import { Log, LZW, Constants } from '../utils/index.js';
import { Platform, getRandomUserAgent, getStringBetweenStrings, findFunction, PlayerError } from '../utils/Utils.js';
import type { ICache, FetchFunction } from '../types/index.js';
import * as Constants from '../utils/Constants.js';
import type { ICache } from '../types/Cache.js';
import type { FetchFunction } from '../types/PlatformShim.js';
const TAG = 'Player';
/**
* Represents YouTube's player script. This is required to decipher signatures.
*/
export default class Player {
#nsig_sc;
#sig_sc;
#sig_sc_timestamp;
#player_id;
player_id: string;
sts: number;
nsig_sc?: string;
sig_sc?: string;
po_token?: string;
constructor(signature_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) {
this.#nsig_sc = nsig_sc;
this.#sig_sc = sig_sc;
this.#sig_sc_timestamp = signature_timestamp;
this.#player_id = player_id;
constructor(player_id: string, signature_timestamp: number, sig_sc?: string, nsig_sc?: string) {
this.player_id = player_id;
this.sts = signature_timestamp;
this.nsig_sc = nsig_sc;
this.sig_sc = sig_sc;
}
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise<Player> {
static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch, po_token?: string): Promise<Player> {
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
const res = await fetch(url);
@@ -34,18 +32,25 @@ export default class Player {
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
Log.info(TAG, `Got player id (${player_id}). Checking for cached players..`);
if (!player_id)
throw new PlayerError('Failed to get player id');
// We have the playerID now we can check if we have a cached player
// We have the player id, now we can check if we have a cached player.
if (cache) {
const cached_player = await Player.fromCache(cache, player_id);
if (cached_player)
if (cached_player) {
Log.info(TAG, 'Found up-to-date player data in cache.');
cached_player.po_token = po_token;
return cached_player;
}
}
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
Log.info(TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);
const player_res = await fetch(player_url, {
headers: {
'user-agent': getRandomUserAgent('desktop')
@@ -59,11 +64,15 @@ export default class Player {
const player_js = await player_res.text();
const sig_timestamp = this.extractSigTimestamp(player_js);
const sig_sc = this.extractSigSourceCode(player_js);
const nsig_sc = this.extractNSigSourceCode(player_js);
return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id);
Log.info(TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);
const player = await Player.fromSource(player_id, sig_timestamp, cache, sig_sc, nsig_sc);
player.po_token = po_token;
return player;
}
decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map<string, string>): string {
@@ -75,38 +84,44 @@ export default class Player {
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
if (signature_cipher || cipher) {
const signature = Platform.shim.eval(this.#sig_sc, {
if (this.sig_sc && (signature_cipher || cipher)) {
const signature = Platform.shim.eval(this.sig_sc, {
sig: args.get('s')
});
Log.info(TAG, `Transformed signature from ${args.get('s')} to ${signature}.`);
if (typeof signature !== 'string')
throw new PlayerError('Failed to decipher signature');
const sp = args.get('sp');
sp ?
url_components.searchParams.set(sp, signature) :
if (sp) {
url_components.searchParams.set(sp, signature);
} else {
url_components.searchParams.set('signature', signature);
}
}
const n = url_components.searchParams.get('n');
if (n) {
if (this.nsig_sc && n) {
let nsig;
if (this_response_nsig_cache && this_response_nsig_cache.has(n)) {
nsig = this_response_nsig_cache.get(n) as string;
} else {
nsig = Platform.shim.eval(this.#nsig_sc, {
nsig = Platform.shim.eval(this.nsig_sc, {
nsig: n
});
Log.info(TAG, `Transformed n signature from ${n} to ${nsig}.`);
if (typeof nsig !== 'string')
throw new PlayerError('Failed to decipher nsig');
if (nsig.startsWith('enhanced_except_')) {
console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!');
Log.warn(TAG, 'Could not transform nsig, download may be throttled.');
} else if (this_response_nsig_cache) {
this_response_nsig_cache.set(n, nsig);
}
@@ -115,6 +130,10 @@ export default class Player {
url_components.searchParams.set('n', nsig);
}
// @NOTE: SABR requests should include the PoToken (not base64d, but as bytes!) in the payload.
if (url_components.searchParams.get('sabr') !== '1' && this.po_token)
url_components.searchParams.set('pot', this.po_token);
const client = url_components.searchParams.get('c');
switch (client) {
@@ -138,6 +157,10 @@ export default class Player {
break;
}
const result = url_components.toString();
Log.info(TAG, `Deciphered URL: ${result}`);
return url_components.toString();
}
@@ -159,39 +182,38 @@ export default class Player {
const sig_buf = buffer.slice(12, 12 + sig_len);
const nsig_buf = buffer.slice(12 + sig_len);
const decoder = new TextDecoder();
const sig_sc = LZW.decompress(new TextDecoder().decode(sig_buf));
const nsig_sc = LZW.decompress(new TextDecoder().decode(nsig_buf));
const sig_sc = decoder.decode(sig_buf);
const nsig_sc = decoder.decode(nsig_buf);
return new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
return new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
}
static async fromSource(cache: ICache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise<Player> {
const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id);
static async fromSource(player_id: string, sig_timestamp: number, cache?: ICache, sig_sc?: string, nsig_sc?: string): Promise<Player> {
const player = new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
await player.cache(cache);
return player;
}
async cache(cache?: ICache): Promise<void> {
if (!cache) return;
if (!cache || !this.sig_sc || !this.nsig_sc)
return;
const encoder = new TextEncoder();
const sig_buf = encoder.encode(this.#sig_sc);
const nsig_buf = encoder.encode(this.#nsig_sc);
const sig_buf = encoder.encode(LZW.compress(this.sig_sc));
const nsig_buf = encoder.encode(LZW.compress(this.nsig_sc));
const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
const view = new DataView(buffer);
view.setUint32(0, Player.LIBRARY_VERSION, true);
view.setUint32(4, this.#sig_sc_timestamp, true);
view.setUint32(4, this.sts, true);
view.setUint32(8, sig_buf.byteLength, true);
new Uint8Array(buffer).set(sig_buf, 12);
new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);
await cache.set(this.#player_id, new Uint8Array(buffer));
await cache.set(this.player_id, new Uint8Array(buffer));
}
static extractSigTimestamp(data: string): number {
@@ -204,37 +226,23 @@ export default class Player {
const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');
if (!functions || !calls)
console.warn(new PlayerError('Failed to extract signature decipher algorithm'));
Log.warn(TAG, 'Failed to extract signature decipher algorithm.');
return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
}
static extractNSigSourceCode(data: string): string {
const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`;
if (!sc)
console.warn(new PlayerError('Failed to extract n-token decipher algorithm'));
return sc;
static extractNSigSourceCode(data: string): string | undefined {
const nsig_function = findFunction(data, { includes: 'enhanced_except' });
if (nsig_function) {
return `${nsig_function.result} ${nsig_function.name}(nsig);`;
}
}
get url(): string {
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
get sts(): number {
return this.#sig_sc_timestamp;
}
get nsig_sc(): string {
return this.#nsig_sc;
}
get sig_sc(): string {
return this.#sig_sc;
return new URL(`/s/player/${this.player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
}
static get LIBRARY_VERSION(): number {
return 2;
return 11;
}
}
}

View File

@@ -1,16 +1,18 @@
import OAuth2 from './OAuth2.js';
import { Log, EventEmitter, HTTPClient, LZW } from '../utils/index.js';
import * as Constants from '../utils/Constants.js';
import EventEmitterLike from '../utils/EventEmitterLike.js';
import * as Proto from '../proto/index.js';
import Actions from './Actions.js';
import Player from './Player.js';
import * as Proto from '../proto/index.js';
import type { ICache } from '../types/Cache.js';
import type { FetchFunction } from '../types/PlatformShim.js';
import HTTPClient from '../utils/HTTPClient.js';
import {
generateRandomString, getRandomUserAgent,
InnertubeError, Platform, SessionError
} from '../utils/Utils.js';
import type { DeviceCategory } from '../utils/Utils.js';
import { generateRandomString, getRandomUserAgent, InnertubeError, Platform, SessionError } from '../utils/Utils.js';
import type { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth.js';
import OAuth from './OAuth.js';
import type { FetchFunction, ICache } from '../types/index.js';
import type { OAuth2Tokens, OAuth2AuthErrorEventHandler, OAuth2AuthPendingEventHandler, OAuth2AuthEventHandler } from './OAuth2.js';
export enum ClientType {
WEB = 'WEB',
@@ -23,33 +25,43 @@ export enum ClientType {
TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}
export interface Context {
export type Context = {
client: {
hl: string;
gl: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
screenDensityFloat?: number;
screenHeightPoints?: number;
screenPixelDensity?: number;
screenWidthPoints?: number;
visitorData?: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
androidSdkVersion?: number;
osName: string;
osVersion: string;
platform: string;
clientFormFactor: string;
userInterfaceTheme: string;
userInterfaceTheme?: string;
timeZone: string;
userAgent?: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
originalUrl?: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
mainAppWebInfo?: {
graftUrl: string;
pwaInstallabilityStatus: string;
webDisplayMode: string;
isWebNativeShareAvailable: boolean;
};
memoryTotalKbytes?: string;
configInfo?: {
appInstallData: string;
},
kidsAppInfo?: {
categorySettings: {
enabledCategories: string[];
@@ -68,9 +80,33 @@ export interface Context {
thirdParty?: {
embedUrl: string;
};
request?: {
useSsl: boolean;
internalExperimentFlags: any[];
};
}
export interface SessionOptions {
type ContextData = {
hl: string;
gl: string;
remote_host?: string;
visitor_data: string;
client_name: string;
client_version: string;
os_name: string;
os_version: string;
device_category: string;
time_zone: string;
enable_safety_mode: boolean;
browser_name?: string;
browser_version?: string;
app_install_data?: string;
device_make: string;
device_model: string;
on_behalf_of_user?: string;
}
export type SessionOptions = {
/**
* Language.
*/
@@ -81,8 +117,8 @@ export interface SessionOptions {
location?: string;
/**
* The account index to use. This is useful if you have multiple accounts logged in.
* **NOTE:**
* Only works if you are signed in with cookies.
*
* **NOTE:** Only works if you are signed in with cookies.
*/
account_index?: number;
/**
@@ -91,6 +127,7 @@ export interface SessionOptions {
on_behalf_of_user?: string;
/**
* Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
*
* **NOTE:** Deciphering formats is not possible without the JS player.
*/
retrieve_player?: boolean;
@@ -101,8 +138,15 @@ export interface SessionOptions {
/**
* Specifies whether to generate the session data locally or retrieve it from YouTube.
* This can be useful if you need more performance.
*
* **NOTE:** If you are using the cache option and a session has already been generated, this will be ignored.
* If you want to force a new session to be generated, you must clear the cache or disable session caching.
*/
generate_session_locally?: boolean;
/**
* Specifies whether the session data should be cached.
*/
enable_session_cache?: boolean;
/**
* Platform to use for the session.
*/
@@ -116,7 +160,7 @@ export interface SessionOptions {
*/
timezone?: string;
/**
* Used to cache the deciphering functions from the JS player.
* Used to cache algorithms, session data, and OAuth2 tokens.
*/
cache?: ICache;
/**
@@ -132,56 +176,80 @@ export interface SessionOptions {
* Fetch function to use.
*/
fetch?: FetchFunction;
/**
* Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine client.
*/
po_token?: string;
}
export interface SessionData {
export type SessionData = {
context: Context;
api_key: string;
api_version: string;
}
export type SWSessionData = {
context_data: ContextData;
api_key: string;
api_version: string;
}
export type SessionArgs = {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: ClientType;
enable_safety_mode: boolean;
visitor_data: string;
on_behalf_of_user: string | undefined;
}
const TAG = 'Session';
/**
* Represents an InnerTube session. This holds all the data needed to make requests to YouTube.
*/
export default class Session extends EventEmitterLike {
#api_version: string;
#key: string;
#context: Context;
#account_index: number;
#player?: Player;
oauth: OAuth;
export default class Session extends EventEmitter {
context: Context;
player?: Player;
oauth: OAuth2;
http: HTTPClient;
logged_in: boolean;
actions: Actions;
cache?: ICache;
key: string;
api_version: string;
account_index: number;
po_token?: string;
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache) {
constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache, po_token?: string) {
super();
this.#context = context;
this.#account_index = account_index;
this.#key = api_key;
this.#api_version = api_version;
this.#player = player;
this.http = new HTTPClient(this, cookie, fetch);
this.actions = new Actions(this);
this.oauth = new OAuth(this);
this.oauth = new OAuth2(this);
this.logged_in = !!cookie;
this.cache = cache;
this.account_index = account_index;
this.key = api_key;
this.api_version = api_version;
this.context = context;
this.player = player;
this.po_token = po_token;
}
on(type: 'auth', listener: OAuthAuthEventHandler): void;
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
on(type: 'update-credentials', listener: OAuthAuthEventHandler): void;
on(type: 'auth', listener: OAuth2AuthEventHandler): void;
on(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
on(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;
on(type: 'update-credentials', listener: OAuth2AuthEventHandler): void;
on(type: string, listener: (...args: any[]) => void): void {
super.on(type, listener);
}
once(type: 'auth', listener: OAuthAuthEventHandler): void;
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
once(type: 'auth', listener: OAuth2AuthEventHandler): void;
once(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
once(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;
once(type: string, listener: (...args: any[]) => void): void {
super.once(type, listener);
@@ -199,16 +267,60 @@ export default class Session extends EventEmitterLike {
options.client_type,
options.timezone,
options.fetch,
options.on_behalf_of_user
options.on_behalf_of_user,
options.cache,
options.enable_session_cache,
options.po_token
);
return new Session(
context, api_key, api_version, account_index,
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch),
options.cookie, options.fetch, options.cache
options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch, options.po_token),
options.cookie, options.fetch, options.cache, options.po_token
);
}
/**
* Retrieves session data from cache.
* @param cache - A valid cache implementation.
* @param session_args - User provided session arguments.
*/
static async fromCache(cache: ICache, session_args: SessionArgs): Promise<SessionData | null> {
const buffer = await cache.get('innertube_session_data');
if (!buffer)
return null;
const data = new TextDecoder().decode(buffer.slice(4));
try {
const result = JSON.parse(LZW.decompress(data)) as SessionData;
if (session_args.visitor_data) {
result.context.client.visitorData = session_args.visitor_data;
}
if (session_args.lang)
result.context.client.hl = session_args.lang;
if (session_args.location)
result.context.client.gl = session_args.location;
if (session_args.on_behalf_of_user)
result.context.user.onBehalfOfUser = session_args.on_behalf_of_user;
result.context.client.timeZone = session_args.time_zone;
result.context.client.platform = session_args.device_category.toUpperCase();
result.context.client.clientName = session_args.client_name;
result.context.user.enableSafetyMode = session_args.enable_safety_mode;
return result;
} catch (error) {
Log.error(TAG, 'Failed to parse session data from cache.', error);
return null;
}
}
static async getSessionData(
lang = '',
location = '',
@@ -220,52 +332,102 @@ export default class Session extends EventEmitterLike {
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = Platform.shim.fetch,
on_behalf_of_user?: string
on_behalf_of_user?: string,
cache?: ICache,
enable_session_cache = true,
po_token?: string
) {
let session_data: SessionData;
const session_args = { lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data, on_behalf_of_user, po_token };
const session_args = { lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data, on_behalf_of_user };
let session_data: SessionData | undefined;
if (generate_session_locally) {
session_data = this.#generateSessionData(session_args);
} else {
try {
// This can fail if the data changes or the request is blocked for some reason.
session_data = await this.#retrieveSessionData(session_args, fetch);
} catch (err) {
session_data = this.#generateSessionData(session_args);
if (cache && enable_session_cache) {
const cached_session_data = await this.fromCache(cache, session_args);
if (cached_session_data) {
Log.info(TAG, 'Found session data in cache.');
session_data = cached_session_data;
}
}
if (!session_data) {
Log.info(TAG, 'Generating session data.');
let api_key = Constants.CLIENTS.WEB.API_KEY;
let api_version = Constants.CLIENTS.WEB.API_VERSION;
let context_data: ContextData = {
hl: lang || 'en',
gl: location || 'US',
remote_host: '',
visitor_data: visitor_data || Proto.encodeVisitorData(generateRandomString(11), Math.floor(Date.now() / 1000)),
client_name: client_name,
client_version: Constants.CLIENTS.WEB.VERSION,
device_category: device_category.toUpperCase(),
os_name: 'Windows',
os_version: '10.0',
time_zone: tz,
browser_name: 'Chrome',
browser_version: '125.0.0.0',
device_make: '',
device_model: '',
enable_safety_mode: enable_safety_mode
};
if (!generate_session_locally) {
try {
const sw_session_data = await this.#getSessionData(session_args, fetch);
api_key = sw_session_data.api_key;
api_version = sw_session_data.api_version;
context_data = sw_session_data.context_data;
} catch (error) {
Log.error(TAG, 'Failed to retrieve session data from server. Session data generated locally will be used instead.', error);
}
}
session_data = {
api_key,
api_version,
context: this.#buildContext(context_data)
};
if (enable_session_cache)
await this.#storeSession(session_data, cache);
}
Log.debug(TAG, 'Session data:', session_data);
return { ...session_data, account_index };
}
static async #retrieveSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: string;
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
on_behalf_of_user?: string;
}, fetch: FetchFunction = Platform.shim.fetch): Promise<SessionData> {
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
static async #storeSession(session_data: SessionData, cache?: ICache) {
if (!cache) return;
Log.info(TAG, 'Compressing and caching session data.');
const compressed_session_data = new TextEncoder().encode(LZW.compress(JSON.stringify(session_data)));
const buffer = new ArrayBuffer(4 + compressed_session_data.byteLength);
new DataView(buffer).setUint32(0, compressed_session_data.byteLength, true); // (Luan) XX: Leave this here for debugging purposes
new Uint8Array(buffer).set(compressed_session_data, 4);
await cache.set('innertube_session_data', new Uint8Array(buffer));
}
static async #getSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SWSessionData> {
let visitor_id = generateRandomString(11);
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
if (options.visitor_data)
visitor_id = this.#getVisitorID(options.visitor_data);
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
const res = await fetch(url, {
headers: {
'accept-language': options.lang || 'en-US',
'user-agent': getRandomUserAgent('desktop'),
'accept': '*/*',
'referer': 'https://www.youtube.com/sw.js',
'cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
'Accept-Language': options.lang || 'en-US',
'User-Agent': getRandomUserAgent('desktop'),
'Accept': '*/*',
'Referer': `${Constants.URLS.YT_BASE}/sw.js`,
'Cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
}
});
@@ -273,123 +435,114 @@ export default class Session extends EventEmitterLike {
throw new SessionError(`Failed to retrieve session data: ${res.status}`);
const text = await res.text();
if (!text.startsWith(')]}\''))
throw new SessionError('Invalid JSPB response');
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
const ytcfg = data[0][2];
const api_version = `v${ytcfg[0][0][6]}`;
const api_version = Constants.CLIENTS.WEB.API_VERSION;
const [ [ device_info ], api_key ] = ytcfg;
const context: Context = {
client: {
hl: device_info[0],
gl: options.location || device_info[2],
remoteHost: device_info[3],
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: device_info[13],
clientName: options.client_name,
clientVersion: device_info[16],
osName: device_info[17],
osVersion: device_info[18],
platform: options.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: device_info[79] || options.time_zone,
browserName: device_info[86],
browserVersion: device_info[87],
originalUrl: Constants.URLS.YT_BASE,
deviceMake: device_info[11],
deviceModel: device_info[12],
utcOffsetMinutes: -new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
}
const config_info = device_info[61];
const app_install_data = config_info[config_info.length - 1];
const context_info = {
hl: options.lang || device_info[0],
gl: options.location || device_info[2],
remote_host: device_info[3],
visitor_data: options.visitor_data || device_info[13],
client_name: options.client_name,
client_version: device_info[16],
os_name: device_info[17],
os_version: device_info[18],
time_zone: device_info[79] || options.time_zone,
device_category: options.device_category,
browser_name: device_info[86],
browser_version: device_info[87],
device_make: device_info[11],
device_model: device_info[12],
app_install_data: app_install_data,
enable_safety_mode: options.enable_safety_mode
};
return { context, api_key, api_version };
return { context_data: context_info, api_key, api_version };
}
static #generateSessionData(options: {
lang: string;
location: string;
time_zone: string;
device_category: DeviceCategory;
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
on_behalf_of_user?: string;
}): SessionData {
let visitor_id = generateRandomString(11);
if (options.visitor_data) {
const decoded_visitor_data = Proto.decodeVisitorData(options.visitor_data);
visitor_id = decoded_visitor_data.id;
}
static #buildContext(args: ContextData) {
const context: Context = {
client: {
hl: options.lang || 'en',
gl: options.location || 'US',
hl: args.hl,
gl: args.gl,
remoteHost: args.remote_host,
screenDensityFloat: 1,
screenHeightPoints: 1080,
screenHeightPoints: 1440,
screenPixelDensity: 1,
screenWidthPoints: 1920,
visitorData: Proto.encodeVisitorData(visitor_id, Math.floor(Date.now() / 1000)),
clientName: options.client_name,
clientVersion: Constants.CLIENTS.WEB.VERSION,
osName: 'Windows',
osVersion: '10.0',
platform: options.device_category.toUpperCase(),
screenWidthPoints: 2560,
visitorData: args.visitor_data,
clientName: args.client_name,
clientVersion: args.client_version,
osName: args.os_name,
osVersion: args.os_version,
platform: args.device_category.toUpperCase(),
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
timeZone: options.time_zone,
timeZone: args.time_zone,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: '',
deviceModel: '',
utcOffsetMinutes: -new Date().getTimezoneOffset()
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
}
};
return { context, api_key: Constants.CLIENTS.WEB.API_KEY, api_version: Constants.CLIENTS.WEB.API_VERSION };
}
async signIn(credentials?: Credentials): Promise<void> {
return new Promise(async (resolve, reject) => {
const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err);
this.once('auth', (data) => {
this.off('auth-error', error_handler);
if (data.status === 'SUCCESS') {
this.logged_in = true;
resolve();
deviceMake: args.device_make,
deviceModel: args.device_model,
browserName: args.browser_name,
browserVersion: args.browser_version,
utcOffsetMinutes: -Math.floor((new Date()).getTimezoneOffset()),
memoryTotalKbytes: '8000000',
mainAppWebInfo: {
graftUrl: Constants.URLS.YT_BASE,
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
isWebNativeShareAvailable: true
}
},
user: {
enableSafetyMode: args.enable_safety_mode,
lockedSafetyMode: false
},
request: {
useSsl: true,
internalExperimentFlags: []
}
};
reject(data);
});
if (args.app_install_data)
context.client.configInfo = { appInstallData: args.app_install_data };
if (args.on_behalf_of_user)
context.user.onBehalfOfUser = args.on_behalf_of_user;
return context;
}
static #getVisitorID(visitor_data: string) {
const decoded_visitor_data = Proto.decodeVisitorData(visitor_data);
return decoded_visitor_data.id;
}
async signIn(credentials?: OAuth2Tokens): Promise<void> {
return new Promise(async (resolve, reject) => {
const error_handler: OAuth2AuthErrorEventHandler = (err) => reject(err);
this.once('auth-error', error_handler);
this.once('auth', () => {
this.off('auth-error', error_handler);
this.logged_in = true;
resolve();
});
try {
await this.oauth.init(credentials);
if (this.oauth.validateCredentials()) {
await this.oauth.refreshIfRequired();
this.logged_in = true;
resolve();
}
} catch (err) {
reject(err);
}
@@ -409,41 +562,15 @@ export default class Session extends EventEmitterLike {
return response;
}
/**
* InnerTube API key.
*/
get key(): string {
return this.#key;
}
/**
* InnerTube API version.
*/
get api_version(): string {
return this.#api_version;
}
get client_version(): string {
return this.#context.client.clientVersion;
return this.context.client.clientVersion;
}
get client_name(): string {
return this.#context.client.clientName;
}
get account_index(): number {
return this.#account_index;
}
get context(): Context {
return this.#context;
}
get player(): Player | undefined {
return this.#player;
return this.context.client.clientName;
}
get lang(): string {
return this.#context.client.hl;
return this.context.client.hl;
}
}
}

View File

@@ -1,12 +1,7 @@
import { Parser } from '../../parser/index.js';
import Channel from '../../parser/ytkids/Channel.js';
import HomeFeed from '../../parser/ytkids/HomeFeed.js';
import Search from '../../parser/ytkids/Search.js';
import VideoInfo from '../../parser/ytkids/VideoInfo.js';
import type Session from '../Session.js';
import { type ApiResponse } from '../Actions.js';
import { Channel, HomeFeed, Search, VideoInfo } from '../../parser/ytkids/index.js';
import { InnertubeError, generateRandomString } from '../../utils/Utils.js';
import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.js';
import {
BrowseEndpoint, NextEndpoint,
@@ -15,7 +10,7 @@ import {
import { BlocklistPickerEndpoint } from '../endpoints/kids/index.js';
import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.js';
import type { Session, ApiResponse } from '../index.js';
export default class Kids {
#session: Session;

View File

@@ -1,16 +1,14 @@
import Album from '../../parser/ytmusic/Album.js';
import Artist from '../../parser/ytmusic/Artist.js';
import Explore from '../../parser/ytmusic/Explore.js';
import HomeFeed from '../../parser/ytmusic/HomeFeed.js';
import Library from '../../parser/ytmusic/Library.js';
import Playlist from '../../parser/ytmusic/Playlist.js';
import Recap from '../../parser/ytmusic/Recap.js';
import Search from '../../parser/ytmusic/Search.js';
import TrackInfo from '../../parser/ytmusic/TrackInfo.js';
import * as Proto from '../../proto/index.js';
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.js';
import {
Album, Artist, Explore,
HomeFeed, Library, Playlist,
Recap, Search, TrackInfo
} from '../../parser/ytmusic/index.js';
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js';
import Message from '../../parser/classes/Message.js';
import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.js';
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js';
import MusicQueue from '../../parser/classes/MusicQueue.js';
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js';
@@ -18,13 +16,6 @@ import PlaylistPanel from '../../parser/classes/PlaylistPanel.js';
import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.js';
import SectionList from '../../parser/classes/SectionList.js';
import Tab from '../../parser/classes/Tab.js';
import * as Proto from '../../proto/index.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type { MusicSearchFilters } from '../../types/index.js';
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.js';
import type Actions from '../Actions.js';
import type Session from '../Session.js';
import {
BrowseEndpoint,
@@ -35,6 +26,10 @@ import {
import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type { MusicSearchFilters } from '../../types/index.js';
import type { Actions, Session } from '../index.js';
export default class Music {
#session: Session;
#actions: Actions;
@@ -282,7 +277,7 @@ export default class Music {
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
async getRelated(video_id: string): Promise<SectionList | Message> {
throwIfMissing({ video_id });
const response = await this.#actions.execute(
@@ -301,9 +296,9 @@ export default class Music {
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
const contents = page.contents.item().as(SectionList, Message);
return shelves;
return contents;
}
/**

View File

@@ -1,13 +1,11 @@
import * as Proto from '../../proto/index.js';
import * as Constants from '../../utils/Constants.js';
import { Constants } from '../../utils/index.js';
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js';
import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Clients.js';
import type { ApiResponse } from '../Actions.js';
import type Session from '../Session.js';
import { CreateVideoEndpoint } from '../endpoints/upload/index.js';
import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Misc.js';
import type { ApiResponse, Session } from '../index.js';
interface UploadResult {
status: string;
scottyResourceId: string;

View File

@@ -8,12 +8,7 @@ export const PATH = '/player';
* @returns The payload.
*/
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
const is_android =
opts.client === 'ANDROID' ||
opts.client === 'YTMUSIC_ANDROID' ||
opts.client === 'YTSTUDIO_ANDROID';
return {
const payload: IPlayerRequest = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
@@ -38,12 +33,22 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
},
racyCheckOk: true,
contentCheckOk: true,
videoId: opts.video_id,
...{
client: opts.client,
playlistId: opts.playlist_id,
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
params: is_android ? '2AMBCgIQBg' : opts.params
}
videoId: opts.video_id
};
if (opts.client)
payload.client = opts.client;
if (opts.playlist_id)
payload.playlistId = opts.playlist_id;
if (opts.params)
payload.params = opts.params;
if (opts.po_token)
payload.serviceIntegrityDimensions = {
poToken: opts.po_token
};
return payload;
}

View File

@@ -1,6 +1,5 @@
import type { IMusicGetSearchSuggestionsRequest, MusicGetSearchSuggestionsEndpointOptions } from '../../../types/index.js';
export const PATH = '/music/get_search_suggestions';
/**

View File

@@ -0,0 +1,20 @@
import type { IReelItemWatchRequest, ReelItemWatchEndpointOptions } from '../../../types/index.js';
export const PATH = '/reel/reel_item_watch';
/**
* Builds a `/reel/reel_watch_sequence` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ReelItemWatchEndpointOptions): IReelItemWatchRequest {
return {
disablePlayerResponse: false,
playerRequest: {
videoId: opts.video_id,
params: opts.params ?? 'CAUwAg%3D%3D'
},
params: opts.params ?? 'CAUwAg%3D%3D',
client: opts.client
};
}

View File

@@ -1,4 +1,4 @@
import type { IReelSequenceRequest, ReelWatchSequenceEndpointOptions } from '../../../types/index.js';
import type { IReelWatchSequenceRequest, ReelWatchSequenceEndpointOptions } from '../../../types/index.js';
export const PATH = '/reel/reel_watch_sequence';
@@ -7,8 +7,8 @@ export const PATH = '/reel/reel_watch_sequence';
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ReelWatchSequenceEndpointOptions): IReelSequenceRequest {
export function build(opts: ReelWatchSequenceEndpointOptions): IReelWatchSequenceRequest {
return {
sequenceParams: opts.sequenceParams
sequenceParams: opts.sequence_params
};
}

View File

@@ -1,18 +0,0 @@
import type { IReelWatchRequest, ReelWatchEndpointOptions } from '../../../types/index.js';
export const PATH = '/reel/reel_item_watch';
/**
* Builds a `/reel/reel_watch_sequence` request payload.
* @param opts - The options to use.
* @returns The payload.
*/
export function build(opts: ReelWatchEndpointOptions): IReelWatchRequest {
return {
playerRequest: {
videoId: opts.short_id,
params: opts.params ?? 'CAUwAg%3D%3D'
},
params: opts.params ?? 'CAUwAg%3D%3D'
};
}

View File

@@ -1,2 +1,2 @@
export * as WatchEndpoint from './WatchEndpoint.js';
export * as WatchSequenceEndpoint from './WatchSequenceEndpoint.js';
export * as ReelItemWatchEndpoint from './ReelItemWatchEndpoint.js';
export * as ReelWatchSequenceEndpoint from './ReelWatchSequenceEndpoint.js';

View File

@@ -7,8 +7,8 @@ export * from './Actions.js';
export { default as Player } from './Player.js';
export * from './Player.js';
export { default as OAuth } from './OAuth.js';
export * from './OAuth.js';
export { default as OAuth2 } from './OAuth2.js';
export * from './OAuth2.js';
export * as Clients from './clients/index.js';
export * as Endpoints from './endpoints/index.js';

View File

@@ -1,3 +1,5 @@
import type { Actions, ApiResponse } from '../index.js';
import AccountInfo from '../../parser/youtube/AccountInfo.js';
import Analytics from '../../parser/youtube/Analytics.js';
import Settings from '../../parser/youtube/Settings.js';
@@ -7,9 +9,6 @@ import * as Proto from '../../proto/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import { Account, BrowseEndpoint, Channel } from '../endpoints/index.js';
import type Actions from '../Actions.js';
import type { ApiResponse } from '../Actions.js';
export default class AccountManager {
#actions: Actions;

View File

@@ -1,6 +1,4 @@
import * as Proto from '../../proto/index.js';
import type Actions from '../Actions.js';
import type { ApiResponse } from '../Actions.js';
import { throwIfMissing } from '../../utils/Utils.js';
import { LikeEndpoint, DislikeEndpoint, RemoveLikeEndpoint } from '../endpoints/like/index.js';
@@ -8,6 +6,8 @@ import { SubscribeEndpoint, UnsubscribeEndpoint } from '../endpoints/subscriptio
import { CreateCommentEndpoint, PerformCommentActionEndpoint } from '../endpoints/comment/index.js';
import { ModifyChannelPreferenceEndpoint } from '../endpoints/notification/index.js';
import type { Actions, ApiResponse } from '../index.js';
export default class InteractionManager {
#actions: Actions;

View File

@@ -1,12 +1,12 @@
import Playlist from '../../parser/youtube/Playlist.js';
import type Actions from '../Actions.js';
import type Feed from '../mixins/Feed.js';
import type { EditPlaylistEndpointOptions } from '../../types/index.js';
import { InnertubeError, throwIfMissing } from '../../utils/Utils.js';
import { EditPlaylistEndpoint } from '../endpoints/browse/index.js';
import { BrowseEndpoint } from '../endpoints/index.js';
import { CreateEndpoint, DeleteEndpoint } from '../endpoints/playlist/index.js';
import Playlist from '../../parser/youtube/Playlist.js';
import type { Actions } from '../index.js';
import type { Feed } from '../mixins/index.js';
import type { EditPlaylistEndpointOptions } from '../../types/index.js';
export default class PlaylistManager {
#actions: Actions;
@@ -96,8 +96,9 @@ export default class PlaylistManager {
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
* @param use_set_video_ids - Option to remove videos using set video IDs.
*/
async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
async removeVideos(playlist_id: string, video_ids: string[], use_set_video_ids = false): Promise<{ playlist_id: string; action_result: any }> {
throwIfMissing({ playlist_id, video_ids });
if (!this.#actions.session.logged_in)
@@ -115,7 +116,8 @@ export default class PlaylistManager {
const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };
const getSetVideoIds = async (pl: Feed): Promise<void> => {
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
const key_id = use_set_video_ids ? 'set_video_id' : 'id';
const videos = pl.videos.filter((video) => video_ids.includes(video.key(key_id).string()));
videos.forEach((video) =>
payload.actions.push({

View File

@@ -1,7 +1,5 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../../parser/helpers.js';
import { Parser, ReloadContinuationItemsCommand } from '../../parser/index.js';
import { concatMemos, InnertubeError } from '../../utils/Utils.js';
import type Actions from '../Actions.js';
import BackstagePost from '../../parser/classes/BackstagePost.js';
import SharedPost from '../../parser/classes/SharedPost.js';
@@ -10,6 +8,7 @@ import CompactVideo from '../../parser/classes/CompactVideo.js';
import GridChannel from '../../parser/classes/GridChannel.js';
import GridPlaylist from '../../parser/classes/GridPlaylist.js';
import GridVideo from '../../parser/classes/GridVideo.js';
import LockupView from '../../parser/classes/LockupView.js';
import Playlist from '../../parser/classes/Playlist.js';
import PlaylistPanelVideo from '../../parser/classes/PlaylistPanelVideo.js';
import PlaylistVideo from '../../parser/classes/PlaylistVideo.js';
@@ -27,12 +26,15 @@ import TwoColumnBrowseResults from '../../parser/classes/TwoColumnBrowseResults.
import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.js';
import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.js';
import type { ApiResponse, Actions } from '../index.js';
import type {
Memo, ObservedArray,
SuperParsedResult, YTNode
} from '../../parser/helpers.js';
import type MusicQueue from '../../parser/classes/MusicQueue.js';
import type RichGrid from '../../parser/classes/RichGrid.js';
import type SectionList from '../../parser/classes/SectionList.js';
import type { IParsedResponse } from '../../parser/types/index.js';
import type { ApiResponse } from '../Actions.js';
export default class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
@@ -87,7 +89,18 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
* Get all playlists on a given page via memo
*/
static getPlaylistsFromMemo(memo: Memo) {
return memo.getType(Playlist, GridPlaylist);
const playlists: ObservedArray<Playlist | GridPlaylist | LockupView> = memo.getType(Playlist, GridPlaylist);
const lockup_views = memo.getType(LockupView)
.filter((lockup) => {
return [ 'PLAYLIST', 'ALBUM', 'PODCAST' ].includes(lockup.content_type);
});
if (lockup_views.length > 0) {
playlists.push(...lockup_views);
}
return playlists;
}
/**
@@ -212,10 +225,10 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
#getBodyContinuations(): ObservedArray<ContinuationItem> {
if (this.#page.header_memo) {
const header_continuations = this.#page.header_memo.getType(ContinuationItem);
return this.#memo.getType(ContinuationItem).filter((continuation) => !header_continuations.includes(continuation)) as ObservedArray<ContinuationItem>;
return this.#memo.getType(ContinuationItem).filter(
(continuation) => !header_continuations.includes(continuation)
) as ObservedArray<ContinuationItem>;
}
return this.#memo.getType(ContinuationItem);
}
}

View File

@@ -1,12 +1,11 @@
import Feed from './Feed.js';
import ChipCloudChip from '../../parser/classes/ChipCloudChip.js';
import FeedFilterChipBar from '../../parser/classes/FeedFilterChipBar.js';
import { InnertubeError } from '../../utils/Utils.js';
import Feed from './Feed.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type { IParsedResponse } from '../../parser/types/ParsedResponse.js';
import type Actions from '../Actions.js';
import type { ApiResponse } from '../Actions.js';
import type { IParsedResponse } from '../../parser/types/index.js';
import type { ApiResponse, Actions } from '../index.js';
export default class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;

View File

@@ -1,32 +1,47 @@
import type { ApiResponse } from '../Actions.js';
import type Actions from '../Actions.js';
import * as Constants from '../../utils/Constants.js';
import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../types/FormatUtils.js';
import * as FormatUtils from '../../utils/FormatUtils.js';
import { Constants, FormatUtils } from '../../utils/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { INextResponse, IPlayerConfig, IPlayerResponse } from '../../parser/index.js';
import { Parser } from '../../parser/index.js';
import type { DashOptions } from '../../types/DashOptions.js';
import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';
import { getStreamingInfo } from '../../utils/StreamingInfo.js';
import { Parser } from '../../parser/index.js';
import { TranscriptInfo } from '../../parser/youtube/index.js';
import ContinuationItem from '../../parser/classes/ContinuationItem.js';
import TranscriptInfo from '../../parser/youtube/TranscriptInfo.js';
import PlayerMicroformat from '../../parser/classes/PlayerMicroformat.js';
import MicroformatData from '../../parser/classes/MicroformatData.js';
import type { ApiResponse, Actions } from '../index.js';
import type { INextResponse, IPlayabilityStatus, IPlaybackTracking, IPlayerConfig, IPlayerResponse, IStreamingData } from '../../parser/index.js';
import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../types/FormatUtils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { DashOptions } from '../../types/DashOptions.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type CardCollection from '../../parser/classes/CardCollection.js';
import type Endscreen from '../../parser/classes/Endscreen.js';
import type PlayerAnnotationsExpanded from '../../parser/classes/PlayerAnnotationsExpanded.js';
import type PlayerCaptionsTracklist from '../../parser/classes/PlayerCaptionsTracklist.js';
import type PlayerLiveStoryboardSpec from '../../parser/classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';
export default class MediaInfo {
#page: [IPlayerResponse, INextResponse?];
#actions: Actions;
#cpn: string;
#playback_tracking;
streaming_data;
playability_status;
player_config: IPlayerConfig;
#playback_tracking?: IPlaybackTracking;
basic_info;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
captions?: PlayerCaptionsTracklist;
cards?: CardCollection;
streaming_data?: IStreamingData;
playability_status?: IPlayabilityStatus;
player_config?: IPlayerConfig;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data.playerResponse ? data[0].data.playerResponse : data[0].data);
const next = data[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
@@ -34,6 +49,38 @@ export default class MediaInfo {
if (info.playability_status?.status === 'ERROR')
throw new InnertubeError('This video is unavailable', info.playability_status);
if (info.microformat && !info.microformat?.is(PlayerMicroformat, MicroformatData))
throw new InnertubeError('Unsupported microformat', info.microformat);
this.basic_info = { // This type is inferred so no need for an explicit type
...info.video_details,
/**
* Microformat is a bit redundant, so only
* a few things there are interesting to us.
*/
...{
embed: info.microformat?.is(PlayerMicroformat) ? info.microformat?.embed : null,
channel: info.microformat?.is(PlayerMicroformat) ? info.microformat?.channel : null,
is_unlisted: info.microformat?.is_unlisted,
is_family_safe: info.microformat?.is_family_safe,
category: info.microformat?.is(PlayerMicroformat) ? info.microformat?.category : null,
has_ypc_metadata: info.microformat?.is(PlayerMicroformat) ? info.microformat?.has_ypc_metadata : null,
start_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.start_timestamp : null,
end_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.end_timestamp : null,
view_count: info.microformat?.is(PlayerMicroformat) && isNaN(info.video_details?.view_count as number) ? info.microformat.view_count : info.video_details?.view_count,
url_canonical: info.microformat?.is(MicroformatData) ? info.microformat?.url_canonical : null,
tags: info.microformat?.is(MicroformatData) ? info.microformat?.tags : null
},
like_count: undefined as number | undefined,
is_liked: undefined as boolean | undefined,
is_disliked: undefined as boolean | undefined
};
this.annotations = info.annotations;
this.storyboards = info.storyboards;
this.endscreen = info.endscreen;
this.captions = info.captions;
this.cards = info.cards;
this.streaming_data = info.streaming_data;
this.playability_status = info.playability_status;
this.player_config = info.player_config;
@@ -50,17 +97,33 @@ export default class MediaInfo {
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
const player_response = this.#page[0];
if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) {
throw new InnertubeError('Generating DASH manifests for live and Post-Live-DVR videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.');
if (player_response.video_details && (player_response.video_details.is_live)) {
throw new InnertubeError('Generating DASH manifests for live videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.');
}
let storyboards;
let captions;
if (options.include_thumbnails && player_response.storyboards?.is(PlayerStoryboardSpec)) {
if (options.include_thumbnails && player_response.storyboards) {
storyboards = player_response.storyboards;
}
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards);
if (typeof options.captions_format === 'string' && player_response.captions?.caption_tracks) {
captions = player_response.captions.caption_tracks;
}
return FormatUtils.toDash(
this.streaming_data,
this.page[0].video_details?.is_post_live_dvr,
url_transformer,
format_filter,
this.#cpn,
this.#actions.session.player,
this.#actions,
storyboards,
captions,
options
);
}
/**
@@ -69,12 +132,13 @@ export default class MediaInfo {
getStreamingInfo(url_transformer?: URLTransformer, format_filter?: FormatFilter) {
return getStreamingInfo(
this.streaming_data,
this.page[0].video_details?.is_post_live_dvr,
url_transformer,
format_filter,
this.cpn,
this.#actions.session.player,
this.#actions,
this.#page[0].storyboards?.is(PlayerStoryboardSpec) ? this.#page[0].storyboards : undefined
this.#page[0].storyboards ? this.#page[0].storyboards : undefined
);
}

View File

@@ -1,11 +1,10 @@
import Tab from '../../parser/classes/Tab.js';
import Feed from './Feed.js';
import { Feed } from './index.js';
import { InnertubeError } from '../../utils/Utils.js';
import Tab from '../../parser/classes/Tab.js';
import type Actions from '../Actions.js';
import type { Actions, ApiResponse } from '../index.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type { IParsedResponse } from '../../parser/types/ParsedResponse.js';
import type { ApiResponse } from '../Actions.js';
export default class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
#tabs?: ObservedArray<Tab>;

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
export default class AttributionView extends YTNode {
static type = 'AttributionView';
text: Text;
suffix: Text;
constructor(data: RawNode) {
super();
this.text = Text.fromAttributed(data.text);
this.suffix = Text.fromAttributed(data.suffix);
}
}

View File

@@ -5,24 +5,20 @@ import { Thumbnail } from '../misc.js';
export default class AvatarView extends YTNode {
static type = 'AvatarView';
image: {
sources: Thumbnail[],
processor: {
border_image_processor: {
circular: boolean
}
image: Thumbnail[];
image_processor: {
border_image_processor: {
circular: boolean
}
};
avatar_image_size: string;
constructor(data: RawNode) {
super();
this.image = {
sources: data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width),
processor: {
border_image_processor: {
circular: data.image.processor.borderImageProcessor.circular
}
this.image = Thumbnail.fromResponse(data.image);
this.image_processor = {
border_image_processor: {
circular: data.image.processor.borderImageProcessor.circular
}
};
this.avatar_image_size = data.avatarImageSize;

View File

@@ -10,7 +10,7 @@ export default class ButtonView extends YTNode {
accessibility_text: string;
style: string;
is_full_width: boolean;
type: string;
button_type: string;
button_size: string;
on_tap: NavigationEndpoint;
@@ -21,7 +21,7 @@ export default class ButtonView extends YTNode {
this.accessibility_text = data.accessibilityText;
this.style = data.style;
this.is_full_width = data.isFullWidth;
this.type = data.type;
this.button_type = data.type;
this.button_size = data.buttonSize;
this.on_tap = new NavigationEndpoint(data.onTap);
}

View File

@@ -1,3 +1,4 @@
import { Log } from '../../utils/index.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
@@ -44,6 +45,7 @@ export default class Channel extends YTNode {
* Please use {@link Channel.subscriber_count} instead.
*/
get subscribers(): Text {
Log.warnOnce(Channel.type, 'Channel#subscribers is deprecated. Please use Channel#subscriber_count instead.');
return this.subscriber_count;
}
@@ -53,6 +55,7 @@ export default class Channel extends YTNode {
* Please use {@link Channel.video_count} instead.
*/
get videos(): Text {
Log.warnOnce(Channel.type, 'Channel#videos is deprecated. Please use Channel#video_count instead.');
return this.video_count;
}
}

View File

@@ -1,3 +1,4 @@
import { Log } from '../../utils/index.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
@@ -55,6 +56,7 @@ export default class ChannelAboutFullMetadata extends YTNode {
* Please use {@link Channel.view_count} instead.
*/
get views() {
Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#views is deprecated. Please use ChannelAboutFullMetadata#view_count instead.');
return this.view_count;
}
@@ -64,6 +66,7 @@ export default class ChannelAboutFullMetadata extends YTNode {
* Please use {@link Channel.joined_date} instead.
*/
get joined(): Text {
Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#joined is deprecated. Please use ChannelAboutFullMetadata#joined_date instead.');
return this.joined_date;
}
}

View File

@@ -1,6 +1,7 @@
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Log } from '../../utils/index.js';
export default class ChannelVideoPlayer extends YTNode {
static type = 'ChannelVideoPlayer';
@@ -26,6 +27,7 @@ export default class ChannelVideoPlayer extends YTNode {
* Please use {@link ChannelVideoPlayer.view_count} instead.
*/
get views(): Text {
Log.warnOnce(ChannelVideoPlayer.type, 'ChannelVideoPlayer#views is deprecated. Please use ChannelVideoPlayer#view_count instead.');
return this.view_count;
}
@@ -35,6 +37,7 @@ export default class ChannelVideoPlayer extends YTNode {
* Please use {@link ChannelVideoPlayer.published_time} instead.
*/
get published(): Text {
Log.warnOnce(ChannelVideoPlayer.type, 'ChannelVideoPlayer#published is deprecated. Please use ChannelVideoPlayer#published_time instead.');
return this.published_time;
}
}

View File

@@ -0,0 +1,14 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ChipView from './ChipView.js';
export default class ChipBarView extends YTNode {
static type = 'ChipBarView';
chips: ObservedArray<ChipView> | null;
constructor(data: RawNode) {
super();
this.chips = Parser.parseArray(data.chips, ChipView);
}
}

View File

@@ -0,0 +1,20 @@
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class ChipView extends YTNode {
static type = 'ChipView';
text: string;
display_type: string;
endpoint: NavigationEndpoint;
chip_entity_key: string;
constructor(data: RawNode) {
super();
this.text = data.text;
this.display_type = data.displayType;
this.endpoint = new NavigationEndpoint(data.tapCommand);
this.chip_entity_key = data.chipEntityKey;
}
}

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailView from './ThumbnailView.js';
export default class CollectionThumbnailView extends YTNode {
static type = 'CollectionThumbnailView';
primary_thumbnail: ThumbnailView | null;
stack_color?: {
light_theme: number;
dark_theme: number;
};
constructor(data: RawNode) {
super();
this.primary_thumbnail = Parser.parseItem(data.primaryThumbnail, ThumbnailView);
if (data.stackColor) {
this.stack_color = {
light_theme: data.stackColor.lightTheme,
dark_theme: data.stackColor.darkTheme
};
}
}
}

View File

@@ -1,14 +0,0 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class Command extends YTNode {
static type = 'Command';
endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.endpoint = new NavigationEndpoint(data);
}
}

View File

@@ -1,5 +1,5 @@
import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray, type SuperParsedResult } from '../helpers.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import MetadataBadge from './MetadataBadge.js';
@@ -13,7 +13,7 @@ export default class CompactVideo extends YTNode {
id: string;
thumbnails: Thumbnail[];
rich_thumbnail?: SuperParsedResult<YTNode>;
rich_thumbnail?: YTNode;
title: Text;
author: Author;
view_count: Text;
@@ -36,7 +36,7 @@ export default class CompactVideo extends YTNode {
this.thumbnails = Thumbnail.fromResponse(data.thumbnail) || null;
if (Reflect.has(data, 'richThumbnail')) {
this.rich_thumbnail = Parser.parse(data.richThumbnail);
this.rich_thumbnail = Parser.parseItem(data.richThumbnail);
}
this.title = new Text(data.title);

View File

@@ -3,7 +3,7 @@ import type { RawNode } from '../index.js';
import { Text } from '../misc.js';
export type MetadataRow = {
metadata_parts: {
metadata_parts?: {
text: Text;
}[];
};
@@ -17,7 +17,7 @@ export default class ContentMetadataView extends YTNode {
constructor(data: RawNode) {
super();
this.metadata_rows = data.metadataRows.map((row: RawNode) => ({
metadata_parts: row.metadataParts.map((part: RawNode) => ({
metadata_parts: row.metadataParts?.map((part: RawNode) => ({
text: Text.fromAttributed(part.text)
}))
}));

View File

@@ -1,19 +1,21 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import AvatarView from './AvatarView.js';
export default class DecoratedAvatarView extends YTNode {
static type = 'DecoratedAvatarView';
avatar: AvatarView;
avatar: AvatarView | null;
a11y_label: string;
on_tap_endpoint: NavigationEndpoint;
on_tap_endpoint?: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.avatar = new AvatarView(data.avatar.avatarViewModel);
this.avatar = Parser.parseItem(data.avatar, AvatarView);
this.a11y_label = data.a11yLabel;
this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap.innertubeCommand);
if (data.rendererContext?.commandContext?.onTap) {
this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap);
}
}
}

View File

@@ -0,0 +1,47 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import EngagementPanelSectionList from './EngagementPanelSectionList.js';
import Text from './misc/Text.js';
export default class DescriptionPreviewView extends YTNode {
static type = 'DescriptionPreviewView';
description: Text;
max_lines: number;
truncation_text: Text;
always_show_truncation_text: boolean;
more_endpoint?: {
show_engagement_panel_endpoint: {
engagement_panel: EngagementPanelSectionList | null,
engagement_panel_popup_type: string;
identifier: {
surface: string,
tag: string
}
}
};
constructor(data: RawNode) {
super();
this.description = Text.fromAttributed(data.description);
this.max_lines = parseInt(data.maxLines);
this.truncation_text = Text.fromAttributed(data.truncationText);
this.always_show_truncation_text = !!data.alwaysShowTruncationText;
if (data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint) {
const endpoint = data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint;
this.more_endpoint = {
show_engagement_panel_endpoint: {
engagement_panel: Parser.parseItem(endpoint.engagementPanel, EngagementPanelSectionList),
engagement_panel_popup_type: endpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType,
identifier: {
surface: endpoint.identifier.surface,
tag: endpoint.identifier.tag
}
}
};
}
}
}

View File

@@ -1,13 +1,16 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
export default class DynamicTextView extends YTNode {
static type = 'DynamicTextView';
text: string;
text: Text;
max_lines: number;
constructor(data: RawNode) {
super();
this.text = data.text.content;
this.text = Text.fromAttributed(data.text);
this.max_lines = parseInt(data.maxLines);
}
}

View File

@@ -0,0 +1,22 @@
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
export default class EomSettingsDisclaimer extends YTNode {
static type = 'EomSettingsDisclaimer';
disclaimer: Text;
info_icon: {
icon_type: string
};
usage_scenario: string;
constructor(data: RawNode) {
super();
this.disclaimer = new Text(data.disclaimer);
this.info_icon = {
icon_type: data.infoIcon.iconType
};
this.usage_scenario = data.usageScenario;
}
}

View File

@@ -7,7 +7,7 @@ export default class Factoid extends YTNode {
label: Text;
value: Text;
accessibility_text: String;
accessibility_text: string;
constructor(data: RawNode) {
super();

View File

@@ -1,9 +1,10 @@
import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ButtonView from './ButtonView.js';
import ToggleButtonView from './ToggleButtonView.js';
export type ActionRow = {
actions: ObservedArray<ButtonView>;
actions: ObservedArray<ButtonView | ToggleButtonView>;
};
export default class FlexibleActionsView extends YTNode {
@@ -15,7 +16,7 @@ export default class FlexibleActionsView extends YTNode {
constructor(data: RawNode) {
super();
this.actions_rows = data.actionsRows.map((row: RawNode) => ({
actions: Parser.parseArray(row.actions, ButtonView)
actions: Parser.parseArray(row.actions, [ ButtonView, ToggleButtonView ])
}));
this.style = data.style;
}

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';
export default class ImageBannerView extends YTNode {
static type = 'ImageBannerView';
image: Thumbnail[];
style: string;
constructor(data: RawNode) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.style = data.style;
}
}

View File

@@ -3,6 +3,7 @@ import { Parser, type RawNode } from '../index.js';
import InfoPanelContent from './InfoPanelContent.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class InfoPanelContainer extends YTNode {
static type = 'InfoPanelContainer';
@@ -10,7 +11,9 @@ export default class InfoPanelContainer extends YTNode {
title: Text;
menu: Menu | null;
content: InfoPanelContent | null;
header_endpoint?: NavigationEndpoint;
background: string;
title_style?: string;
icon_type?: string;
constructor(data: RawNode) {
@@ -18,7 +21,12 @@ export default class InfoPanelContainer extends YTNode {
this.title = new Text(data.title);
this.menu = Parser.parseItem(data.menu, Menu);
this.content = Parser.parseItem(data.content, InfoPanelContent);
if (data.headerEndpoint)
this.header_endpoint = new NavigationEndpoint(data.headerEndpoint);
this.background = data.background;
this.title_style = data.titleStyle;
if (Reflect.has(data, 'icon')) {
this.icon_type = data.icon?.iconType;

View File

@@ -1,5 +1,6 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import type { AttributedText } from './misc/Text.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
@@ -9,7 +10,8 @@ export default class InfoPanelContent extends YTNode {
title: Text;
source: Text;
paragraphs: Text[];
paragraphs?: Text[];
attributed_paragraphs?: Text[];
thumbnail: Thumbnail[];
source_endpoint: NavigationEndpoint;
truncate_paragraphs: boolean;
@@ -20,7 +22,13 @@ export default class InfoPanelContent extends YTNode {
super();
this.title = new Text(data.title);
this.source = new Text(data.source);
this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));
if (Reflect.has(data, 'paragraphs'))
this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));
if (Reflect.has(data, 'attributedParagraphs'))
this.attributed_paragraphs = data.attributedParagraphs.map((p: AttributedText) => Text.fromAttributed(p));
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
this.source_endpoint = new NavigationEndpoint(data.sourceEndpoint);
this.truncate_paragraphs = !!data.truncateParagraphs;

View File

@@ -8,7 +8,7 @@ export default class InfoRow extends YTNode {
title: Text;
default_metadata?: Text;
expanded_metadata?: Text;
info_row_expand_status_key?: String;
info_row_expand_status_key?: string;
constructor(data: RawNode) {
super();

View File

@@ -19,7 +19,7 @@ export default class ItemSection extends YTNode {
this.contents = Parser.parseArray(data.contents);
if (data.targetId || data.sectionIdentifier) {
this.target_id = data.target_id || data.sectionIdentifier;
this.target_id = data.targetId || data.sectionIdentifier;
}
if (data.continuations) {

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ContentMetadataView from './ContentMetadataView.js';
import Text from './misc/Text.js';
export default class LockupMetadataView extends YTNode {
static type = 'LockupMetadataView';
title: Text;
metadata: ContentMetadataView | null;
constructor(data: RawNode) {
super();
this.title = Text.fromAttributed(data.title);
this.metadata = Parser.parseItem(data.metadata, ContentMetadataView);
}
}

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import CollectionThumbnailView from './CollectionThumbnailView.js';
import LockupMetadataView from './LockupMetadataView.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class LockupView extends YTNode {
static type = 'LockupView';
content_image: CollectionThumbnailView | null;
metadata: LockupMetadataView | null;
content_id: string;
content_type: 'SOURCE' | 'PLAYLIST' | 'ALBUM' | 'PODCAST' | 'SHOPPING_COLLECTION' | 'SHORT' | 'GAME' | 'PRODUCT';
on_tap_endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.content_image = Parser.parseItem(data.contentImage, CollectionThumbnailView);
this.metadata = Parser.parseItem(data.metadata, LockupMetadataView);
this.content_id = data.contentId;
this.content_type = data.contentType.replace('LOCKUP_CONTENT_TYPE_', '');
this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap);
}
}

View File

@@ -0,0 +1,19 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
export default class ModalWithTitleAndButton extends YTNode {
static type = 'ModalWithTitleAndButton';
title: Text;
content: Text;
button: Button | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.content = new Text(data.content);
this.button = Parser.parseItem(data.button, Button);
}
}

View File

@@ -5,11 +5,13 @@ export default class MusicEditablePlaylistDetailHeader extends YTNode {
static type = 'MusicEditablePlaylistDetailHeader';
header: YTNode;
edit_header: YTNode;
playlist_id: string;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header);
// TODO: Parse data.editHeader.musicPlaylistEditHeaderRenderer.
this.edit_header = Parser.parseItem(data.editHeader);
this.playlist_id = data.playlistId;
}
}

View File

@@ -0,0 +1,28 @@
import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Dropdown from './Dropdown.js';
import Text from './misc/Text.js';
export default class MusicPlaylistEditHeader extends YTNode {
static type = 'MusicPlaylistEditHeader';
title: Text;
edit_title: Text;
edit_description: Text;
privacy: string;
playlist_id: string;
endpoint: NavigationEndpoint;
privacy_dropdown: Dropdown | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.edit_title = new Text(data.editTitle);
this.edit_description = new Text(data.editDescription);
this.privacy = data.privacy;
this.playlist_id = data.playlistId;
this.endpoint = new NavigationEndpoint(data.collaborationSettingsCommand);
this.privacy_dropdown = Parser.parseItem(data.privacyDropdown, Dropdown);
}
}

View File

@@ -0,0 +1,46 @@
import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import MusicThumbnail from './MusicThumbnail.js';
import MusicDescriptionShelf from './MusicDescriptionShelf.js';
import MusicInlineBadge from './MusicInlineBadge.js';
import MusicPlayButton from './MusicPlayButton.js';
import ToggleButton from './ToggleButton.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import Button from './Button.js';
import DownloadButton from './DownloadButton.js';
import type { ObservedArray } from '../helpers.js';
export default class MusicResponsiveHeader extends YTNode {
static type = 'MusicResponsiveHeader';
thumbnail: MusicThumbnail | null;
buttons: ObservedArray<DownloadButton | ToggleButton | MusicPlayButton | Button | Menu>;
title: Text;
subtitle: Text;
strapline_text_one: Text;
strapline_thumbnail: MusicThumbnail | null;
second_subtitle: Text;
subtitle_badge?: ObservedArray<MusicInlineBadge> | null;
description?: MusicDescriptionShelf | null;
constructor(data: RawNode) {
super();
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
this.buttons = Parser.parseArray(data.buttons, [ DownloadButton, ToggleButton, MusicPlayButton, Button, Menu ]);
this.title = new Text(data.title);
this.subtitle = new Text(data.subtitle);
this.strapline_text_one = new Text(data.straplineTextOne);
this.strapline_thumbnail = Parser.parseItem(data.straplineThumbnail, MusicThumbnail);
this.second_subtitle = new Text(data.secondSubtitle);
if (Reflect.has(data, 'subtitleBadge')) {
this.subtitle_badge = Parser.parseArray(data.subtitleBadge, MusicInlineBadge);
}
if (Reflect.has(data, 'description')) {
this.description = Parser.parseItem(data.description, MusicDescriptionShelf);
}
}
}

View File

@@ -4,6 +4,7 @@ import { YTNode } from '../helpers.js';
import { isTextRun, timeToSeconds } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import type TextRun from './misc/TextRun.js';
import { Parser } from '../index.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
@@ -25,7 +26,7 @@ export default class MusicResponsiveListItem extends YTNode {
};
endpoint?: NavigationEndpoint;
item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'non_music_track' | 'video' | 'song' | 'endpoint' | 'unknown' | undefined;
item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'non_music_track' | 'video' | 'song' | 'endpoint' | 'unknown' | 'podcast_show' | undefined;
index?: Text;
thumbnail?: MusicThumbnail | null;
badges;
@@ -120,6 +121,10 @@ export default class MusicResponsiveListItem extends YTNode {
this.item_type = 'non_music_track';
this.#parseNonMusicTrack();
break;
case 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE':
this.item_type = 'podcast_show';
this.#parsePodcastShow();
break;
default:
if (this.flex_columns[1]) {
this.#parseVideoOrSong();
@@ -160,13 +165,19 @@ export default class MusicResponsiveListItem extends YTNode {
}
#parseVideoOrSong() {
const is_video = this.flex_columns.at(1)?.title.runs?.some((run) => run.text.match(/(.*?) views/));
if (is_video) {
this.item_type = 'video';
this.#parseVideo();
} else {
this.item_type = 'song';
this.#parseSong();
const music_video_type = (this.flex_columns.at(0)?.title.runs?.at(0) as TextRun)?.endpoint?.payload?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType;
switch (music_video_type) {
case 'MUSIC_VIDEO_TYPE_UGC':
case 'MUSIC_VIDEO_TYPE_OMV':
this.item_type = 'video';
this.#parseVideo();
break;
case 'MUSIC_VIDEO_TYPE_ATV':
this.item_type = 'song';
this.#parseSong();
break;
default:
this.#parseOther();
}
}
@@ -267,6 +278,11 @@ export default class MusicResponsiveListItem extends YTNode {
this.title = this.flex_columns.first().title.toString();
}
#parsePodcastShow() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
}
#parseAlbum() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();

View File

@@ -4,6 +4,7 @@ import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
import CreatePlaylistDialog from './CreatePlaylistDialog.js';
import type ModalWithTitleAndButton from './ModalWithTitleAndButton.js';
import OpenPopupAction from './actions/OpenPopupAction.js';
export default class NavigationEndpoint extends YTNode {
@@ -11,8 +12,11 @@ export default class NavigationEndpoint extends YTNode {
payload;
dialog?: CreatePlaylistDialog | YTNode | null;
modal?: ModalWithTitleAndButton | YTNode | null;
open_popup?: OpenPopupAction | null;
next_endpoint?: NavigationEndpoint;
metadata: {
url?: string;
api_url?: string;
@@ -23,8 +27,8 @@ export default class NavigationEndpoint extends YTNode {
constructor(data: RawNode) {
super();
if (Reflect.has(data || {}, 'innertubeCommand'))
data = data.innertubeCommand;
if (data && (data.innertubeCommand || data.command))
data = data.innertubeCommand || data.command;
if (Reflect.has(data || {}, 'openPopupAction'))
this.open_popup = new OpenPopupAction(data.openPopupAction);
@@ -41,6 +45,13 @@ export default class NavigationEndpoint extends YTNode {
this.dialog = Parser.parseItem(this.payload.dialog || this.payload.content);
}
if (Reflect.has(this.payload, 'modal')) {
this.modal = Parser.parseItem(this.payload.modal);
}
if (Reflect.has(this.payload, 'nextEndpoint')) {
this.next_endpoint = new NavigationEndpoint(this.payload.nextEndpoint);
}
if (data?.serviceEndpoint) {
data = data.serviceEndpoint;
@@ -81,13 +92,14 @@ export default class NavigationEndpoint extends YTNode {
case 'browseEndpoint':
return '/browse';
case 'watchEndpoint':
case 'reelWatchEndpoint':
return '/player';
case 'searchEndpoint':
return '/search';
case 'watchPlaylistEndpoint':
return '/next';
case 'liveChatItemContextMenuEndpoint':
return 'live_chat/get_item_context_menu';
return '/live_chat/get_item_context_menu';
}
}

View File

@@ -5,6 +5,9 @@ import ContentPreviewImageView from './ContentPreviewImageView.js';
import DecoratedAvatarView from './DecoratedAvatarView.js';
import DynamicTextView from './DynamicTextView.js';
import FlexibleActionsView from './FlexibleActionsView.js';
import DescriptionPreviewView from './DescriptionPreviewView.js';
import AttributionView from './AttributionView.js';
import ImageBannerView from './ImageBannerView.js';
export default class PageHeaderView extends YTNode {
static type = 'PageHeaderView';
@@ -13,6 +16,9 @@ export default class PageHeaderView extends YTNode {
image: ContentPreviewImageView | DecoratedAvatarView | null;
metadata: ContentMetadataView | null;
actions: FlexibleActionsView | null;
description: DescriptionPreviewView | null;
attributation: AttributionView | null;
banner: ImageBannerView | null;
constructor(data: RawNode) {
super();
@@ -20,5 +26,8 @@ export default class PageHeaderView extends YTNode {
this.image = Parser.parseItem(data.image, [ ContentPreviewImageView, DecoratedAvatarView ]);
this.metadata = Parser.parseItem(data.metadata, ContentMetadataView);
this.actions = Parser.parseItem(data.actions, FlexibleActionsView);
this.description = Parser.parseItem(data.description, DescriptionPreviewView);
this.attributation = Parser.parseItem(data.attributation, AttributionView);
this.banner = Parser.parseItem(data.banner, ImageBannerView);
}
}

View File

@@ -2,17 +2,19 @@ import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface CaptionTrackData {
base_url: string;
name: Text;
vss_id: string;
language_code: string;
kind?: 'asr' | 'frc';
is_translatable: boolean;
}
export default class PlayerCaptionsTracklist extends YTNode {
static type = 'PlayerCaptionsTracklist';
caption_tracks?: {
base_url: string;
name: Text;
vss_id: string;
language_code: string;
kind: string;
is_translatable: boolean;
}[];
caption_tracks?: CaptionTrackData[];
audio_tracks?: {
audio_track_id: string;

View File

@@ -1,11 +1,32 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface LiveStoryboardData {
type: 'live',
template_url: string,
thumbnail_width: number,
thumbnail_height: number,
columns: number,
rows: number
}
export default class PlayerLiveStoryboardSpec extends YTNode {
static type = 'PlayerLiveStoryboardSpec';
constructor() {
board: LiveStoryboardData;
constructor(data: RawNode) {
super();
// TODO: A little bit different from PlayerLiveStoryboardSpec
// https://i.ytimg.com/sb/5qap5aO4i9A/storyboard_live_90_2x2_b2/M$M.jpg?rs=AOn4CLC9s6IeOsw_gKvEbsbU9y-e2FVRTw#159#90#2#2
const [ template_url, thumbnail_width, thumbnail_height, columns, rows ] = data.spec.split('#');
this.board = {
type: 'live',
template_url,
thumbnail_width: parseInt(thumbnail_width, 10),
thumbnail_height: parseInt(thumbnail_height, 10),
columns: parseInt(columns, 10),
rows: parseInt(rows, 10)
};
}
}

View File

@@ -36,6 +36,7 @@ export default class PlayerMicroformat extends YTNode {
upload_date: string;
available_countries: string[];
start_timestamp: Date | null;
end_timestamp: Date | null;
constructor(data: RawNode) {
super();
@@ -70,5 +71,6 @@ export default class PlayerMicroformat extends YTNode {
this.upload_date = data.uploadDate;
this.available_countries = data.availableCountries;
this.start_timestamp = data.liveBroadcastDetails?.startTimestamp ? new Date(data.liveBroadcastDetails.startTimestamp) : null;
this.end_timestamp = data.liveBroadcastDetails?.endTimestamp ? new Date(data.liveBroadcastDetails.endTimestamp) : null;
}
}

View File

@@ -2,6 +2,7 @@ import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface StoryboardData {
type: 'vod'
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
@@ -31,6 +32,7 @@ export default class PlayerStoryboardSpec extends YTNode {
const storyboard_count = Math.ceil(parseInt(thumbnail_count, 10) / (parseInt(columns, 10) * parseInt(rows, 10)));
return {
type: 'vod',
template_url: url.toString().replace('$L', i).replace('$N', name),
thumbnail_width: parseInt(thumbnail_width, 10),
thumbnail_height: parseInt(thumbnail_height, 10),

View File

@@ -23,6 +23,7 @@ export default class PlaylistVideo extends YTNode {
upcoming?: Date;
video_info: Text;
accessibility_label?: string;
style?: string;
duration: {
text: string;
@@ -44,6 +45,10 @@ export default class PlaylistVideo extends YTNode {
this.video_info = new Text(data.videoInfo);
this.accessibility_label = data.title.accessibility.accessibilityData.label;
if (Reflect.has(data, 'style')) {
this.style = data.style;
}
const upcoming = data.upcomingEventData && Number(`${data.upcomingEventData.startTime}000`);
if (upcoming) {
this.upcoming = new Date(upcoming);

View File

@@ -6,6 +6,7 @@ import InfoPanelContainer from './InfoPanelContainer.js';
import LikeButton from './LikeButton.js';
import ReelPlayerHeader from './ReelPlayerHeader.js';
import PivotButton from './PivotButton.js';
import SubscribeButton from './SubscribeButton.js';
export default class ReelPlayerOverlay extends YTNode {
static type = 'ReelPlayerOverlay';
@@ -29,7 +30,7 @@ export default class ReelPlayerOverlay extends YTNode {
this.menu = Parser.parseItem(data.menu, Menu);
this.next_item_button = Parser.parseItem(data.nextItemButton, Button);
this.prev_item_button = Parser.parseItem(data.prevItemButton, Button);
this.subscribe_button_renderer = Parser.parseItem(data.subscribeButtonRenderer, Button);
this.subscribe_button_renderer = Parser.parseItem(data.subscribeButtonRenderer, [ Button, SubscribeButton ]);
this.style = data.style;
this.view_comments_button = Parser.parseItem(data.viewCommentsButton, Button);
this.share_button = Parser.parseItem(data.shareButton, Button);

View File

@@ -8,6 +8,7 @@ import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
import VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.js';
import VideoDescriptionCourseSection from './VideoDescriptionCourseSection.js';
import ReelShelf from './ReelShelf.js';
import VideoAttributesSectionView from './VideoAttributesSectionView.js';
export default class StructuredDescriptionContent extends YTNode {
static type = 'StructuredDescriptionContent';
@@ -15,7 +16,7 @@ export default class StructuredDescriptionContent extends YTNode {
items: ObservedArray<
VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection |
VideoDescriptionInfocardsSection | VideoDescriptionTranscriptSection | VideoDescriptionTranscriptSection |
VideoDescriptionCourseSection | HorizontalCardList | ReelShelf
VideoDescriptionCourseSection | HorizontalCardList | ReelShelf | VideoAttributesSectionView
>;
constructor(data: RawNode) {
@@ -23,7 +24,7 @@ export default class StructuredDescriptionContent extends YTNode {
this.items = Parser.parseArray(data.items, [
VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection,
VideoDescriptionInfocardsSection, VideoDescriptionCourseSection, VideoDescriptionTranscriptSection,
VideoDescriptionTranscriptSection, HorizontalCardList, ReelShelf
VideoDescriptionTranscriptSection, HorizontalCardList, ReelShelf, VideoAttributesSectionView
]);
}
}

View File

@@ -0,0 +1,28 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export default class ThumbnailBadgeView extends YTNode {
static type = 'ThumbnailBadgeView';
icon_name: string;
text: string;
badge_style: string;
background_color?: {
light_theme: number;
dark_theme: number;
};
constructor(data: RawNode) {
super();
this.icon_name = data.icon.sources[0].clientResource.imageName;
this.text = data.text;
this.badge_style = data.badgeStyle;
if (data.backgroundColor) {
this.background_color = {
light_theme: data.backgroundColor.lightTheme,
dark_theme: data.backgroundColor.darkTheme
};
}
}
}

View File

@@ -0,0 +1,19 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
export default class ThumbnailHoverOverlayView extends YTNode {
static type = 'ThumbnailHoverOverlayView';
icon_name: string;
text: Text;
style: string;
constructor(data: RawNode) {
super();
this.icon_name = data.icon.sources[0].clientResource.imageName;
this.text = Text.fromAttributed(data.text);
this.style = data.style;
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailBadgeView from './ThumbnailBadgeView.js';
export default class ThumbnailOverlayBadgeView extends YTNode {
static type = 'ThumbnailOverlayBadgeView';
badges: ThumbnailBadgeView[];
position: string;
constructor(data: RawNode) {
super();
this.badges = Parser.parseArray(data.thumbnailBadges, ThumbnailBadgeView);
this.position = data.position;
}
}

View File

@@ -4,10 +4,10 @@ import type { RawNode } from '../index.js';
export default class ThumbnailOverlayResumePlayback extends YTNode {
static type = 'ThumbnailOverlayResumePlayback';
percent_duration_watched: string; // TODO: is this a number?
percent_duration_watched: number;
constructor(data: RawNode) {
super();
this.percent_duration_watched = data.percentDurationWatched;
}
}
}

View File

@@ -17,8 +17,8 @@ export default class ThumbnailOverlayToggleButton extends YTNode {
untoggled: string;
};
toggled_endpoint: NavigationEndpoint;
untoggled_endpoint: NavigationEndpoint;
toggled_endpoint?: NavigationEndpoint;
untoggled_endpoint?: NavigationEndpoint;
constructor(data: RawNode) {
super();
@@ -36,7 +36,10 @@ export default class ThumbnailOverlayToggleButton extends YTNode {
untoggled: data.untoggledTooltip
};
this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint);
this.untoggled_endpoint = new NavigationEndpoint(data.untoggledServiceEndpoint);
if (data.toggledServiceEndpoint)
this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint);
if (data.untoggledServiceEndpoint)
this.untoggled_endpoint = new NavigationEndpoint(data.untoggledServiceEndpoint);
}
}

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