Compare commits

...

57 Commits

Author SHA1 Message Date
github-actions[bot]
bff4210349 chore(main): release 6.3.0 (#495)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-31 20:41:06 -03:00
absidue
91de6e5c0e feat(ChannelMetadata): Add music_artist_name (#497) 2023-08-31 20:29:58 -03:00
absidue
c26972c42a fix(CompactMovie): Add missing import and remove unnecessary console.log (#496) 2023-08-31 20:28:25 -03:00
Jeremy Banks
8bc2aaa358 feat(Session): Add on_behalf_of_user session option. (#494)
This specifies which channel to use if multiple are associated with the logged-in account.
2023-08-31 08:20:37 -03:00
github-actions[bot]
2e5f076fd7 chore(main): release 6.2.0 (#491)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-29 14:25:32 -03:00
absidue
0412fa05ff fix(Format): Fix is_original always being true (#492) 2023-08-29 14:23:08 -03:00
Luan
10c15bfb9f feat(Session): Add fallback for session data retrieval (#490)
* feat(Session): Add fallback for session data retrieval

Should have added this when we first implemented session data retrieval to be honest. It makes a request to YouTube's service worker and the data there can change or the request can just fail.

* chore: lint
2023-08-28 07:18:27 -03:00
github-actions[bot]
4862c35cee chore(main): release 6.1.0 (#489)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-27 16:43:10 -03:00
absidue
2eed1726d5 feat(parser): Add CompactMovie (#487) 2023-08-27 16:29:50 -03:00
absidue
8b69587787 feat(parser): Add AlertWithButton (#486) 2023-08-27 16:29:23 -03:00
absidue
ed7be2a675 feat(parser): Add ChannelHeaderLinksView (#484) 2023-08-27 16:28:49 -03:00
github-actions[bot]
361fb4a9f1 chore(main): release 6.0.2 (#481)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-24 17:06:07 -03:00
Daniel Wykerd
1c3ea2acd3 fix: invalid set ids in dash manifest (#480) 2023-08-24 15:59:10 -03:00
github-actions[bot]
859c4585d9 chore(main): release 6.0.1 (#476)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-22 09:07:49 -03:00
LuanRT
751f2b90fd Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2023-08-22 09:06:23 -03:00
LuanRT
90be877d28 fix(SearchSubMenu): Groups not being parsed due to a typo 2023-08-22 09:06:06 -03:00
github-actions[bot]
052632314b chore(main): release 6.0.0 (#461)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-18 08:39:05 -03:00
LuanRT
22a38c0762 feat(Session): Add IOS to ClientType enum 2023-08-18 08:36:17 -03:00
LuanRT
f7614634b6 chore: lint 2023-08-18 08:26:39 -03:00
LuanRT
bf1510b235 fix(parser): Logger logging classdata as [Object object] 2023-08-18 08:17:23 -03:00
LuanRT
815e54b854 feat(MusicResponsiveListItem): Detect non music tracks properly
This should make it easier to identify podcast episodes.
2023-08-18 08:15:04 -03:00
LuanRT
f7666051f6 chore(parser): update MusicCarouselShelf 2023-08-18 08:11:04 -03:00
LuanRT
494ee8776a feat(parser): add MusicMultiRowListItem
Used to display podcasts.
2023-08-18 08:09:04 -03:00
Daniel Wykerd
87ed3960ff refactor!: replace unnecessary classes with pure functions (#468)
* deps: update linkedom

* refactor!: remove YTNodeGenerator in favour of namespaced pure functions

BREAKING CHANGES:
- Removes `YTNodeGenerator` from `import('youtubei.js').Generator` and exposes its functions directly in `import('youtubei.js').Generator`

* refactor!: replace Parser class with pure functions

- Remove Parser class in favour of pure functions
- Merge duplicate classes `AppendContinuationItemsAction` into a single class
- Move continuation parsers into a seperate file
- Add better custom logging support to parser methods as per issue #460

* refactor!: replace Proto class with pure functions

* chore: update package-lock.json

* refactor!: replace FormatUtils with pure functions and JSX components

- Replace linkedom DASH manifest generation with a dependency free JSX implementation
- Remove FormatUtils class in favour of pure functions
- Remove DOMParser requirement
- Remove duplicate types

* refactor: implement changes from #462

* chore: lint

* fix: deno support

* fix: render valid xml document

* fix: wrong function call in DashUtils

* fix: typo in parser

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

* refactor!: move streaming info logic into seperate function

This allows users to access the same data available in the dash manifest while also simplifying the manifest generation

* chore: lint

* refactor: readability improvements & fixes

Remove redundant getAudioTrackGroups
General readability improvements in StreamingInfo.ts
Share response object between `getBitrate` and `getMimeType` as to not make duplicate requests

* build: remove unnecessary step in deno build

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

* refactor: move types to `types` directory

* docs: add back comments lost during refactor

* chore: lint

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2023-08-18 06:49:58 -03:00
LuanRT
eb3cca1e2e chore(example/browser): fix ALR requests failing 2023-08-18 06:33:01 -03:00
LuanRT
9971ffe021 chore: update package.json [skip ci] 2023-08-12 23:52:16 -03:00
LuanRT
7949b3df66 chore(COLLABORATORS.md): add Absidue 2023-08-12 23:50:27 -03:00
LuanRT
aa385142e4 chore: update contact email [skip ci] 2023-08-12 23:45:28 -03:00
LuanRT
6c8a916f0f chore: migrate browser example to Shaka player [skip ci] (#471)
* chore: use Shaka for the browser example

* chore: lint

* fix(HashtagFeed): resolve type casting issue so tests pass
2023-08-12 23:21:20 -03:00
absidue
31d27b1bca fix(Format): Extracting audio language from captions (#470) 2023-08-12 16:01:32 -03:00
LuanRT
cb37c6a17b chore: use ESM for dev scripts [skip ci]
Just to keep things consistent.
2023-08-11 19:29:51 -03:00
absidue
1ff3e1a440 fix(toDash): Format grouping into AdaptationSets (#462) 2023-08-09 16:07:03 -03:00
Ronnie Vega
46fe18b763 feat(VideoInfo): support iOS client (#467) 2023-08-09 04:29:25 -03:00
absidue
0dda97e0b0 perf: Cleanup some unnecessary uses of YTNode#key and Maybe (#463) 2023-08-06 19:15:47 -03:00
absidue
e370116092 fix(Playlist): Only try extracting the subtitle for the first page (#465) 2023-08-06 19:14:21 -03:00
LuanRT
3bc53a8c12 fix(parser): Allow any property in the RawResponse interface 2023-08-01 20:38:15 -03:00
github-actions[bot]
74e1a5e068 chore(main): release 5.8.0 (#459)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-30 16:02:19 -03:00
absidue
0fa5a859ae feat(YouTube Playlist): Add subtitle and fix author optionality (#458) 2023-07-30 15:59:39 -03:00
LuanRT
02a111250a chore: update image links 2023-07-28 07:12:45 -03:00
github-actions[bot]
c1886f9a83 chore(main): release 5.7.1 (#455)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-25 02:46:12 -03:00
LuanRT
5f4cbdb904 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2023-07-25 02:45:00 -03:00
LuanRT
d91695a9ec fix(SearchHeader): remove console.log
Oopsie!
2023-07-25 02:44:43 -03:00
github-actions[bot]
137464ca66 chore(main): release 5.7.0 (#451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-24 20:48:09 -03:00
LuanRT
6997982cf2 feat(parser): Add SearchHeader
We may want to remove the old SearchSubMenu node in the future but YouTube still uses it sometimes, so we will keep it for now.

Closes #452
2023-07-24 20:26:05 -03:00
absidue
18cbc8c038 feat(parser): Add PageHeader (#450) 2023-07-19 19:00:26 -03:00
github-actions[bot]
30ff087587 chore(main): release 5.6.0 (#448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-18 15:34:48 -03:00
absidue
1a034733f6 feat(toDash): Add option to include thumbnails in the manifest (#446)
* feat(toDash): Add option to include thumbnails in the manifest

* refactor: Move toDash function back to MediaInfo class
2023-07-18 02:08:02 -03:00
ChunkyProgrammer
c477b824c0 feat(parser): Add IncludingResultsFor (#447) 2023-07-18 01:53:44 -03:00
github-actions[bot]
7e5c3648c1 chore(main): release 5.5.0 (#441)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-16 17:57:11 -03:00
absidue
bdd98a3b9b feat(Format): Populate audio language from captions when available (#445) 2023-07-16 17:54:26 -03:00
LuanRT
06750aaa74 chore: lint 2023-07-16 17:40:05 -03:00
LuanRT
708c5f7394 feat(parser): add MacroMarkersList (#444)
This should fix a few parsing issues that were happening after recent updates.
2023-07-16 17:38:16 -03:00
LuanRT
a9cdbf7010 feat(parser): Add ShowMiniplayerCommand (#443) 2023-07-16 17:34:42 -03:00
LuanRT
b50d1ef67d fix(StructuredDescriptionContent): items can also be a HorizontalCardList 2023-07-16 17:00:36 -03:00
LuanRT
555d257459 feat(parser): Add CommentsSimplebox parser (#442) 2023-07-16 16:46:46 -03:00
titong0
2aef67876e fix(package): Bump Jinter to fix bad export order (#439)
Version 1.0.0 has an export order which crashes some webpack environments (at least I came across it when using next.js 13). Updating to 1.1.0 fixes it. A bit more context here https://github.com/LuanRT/YouTube.js/issues/432

* chore(package): update lock file

* chore: lint

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2023-07-16 16:23:14 -03:00
ChunkyProgrammer
ae2557d15c feat(parser): Add HashtagTile (#440) 2023-07-16 15:35:55 -03:00
97 changed files with 5244 additions and 8127 deletions

View File

@@ -1,5 +1,133 @@
# Changelog
## [6.3.0](https://github.com/LuanRT/YouTube.js/compare/v6.2.0...v6.3.0) (2023-08-31)
### Features
* **ChannelMetadata:** Add `music_artist_name` ([#497](https://github.com/LuanRT/YouTube.js/issues/497)) ([91de6e5](https://github.com/LuanRT/YouTube.js/commit/91de6e5c0e5b27e6d12ce5db2f500c5ff78b9830))
* **Session:** Add on_behalf_of_user session option. ([#494](https://github.com/LuanRT/YouTube.js/issues/494)) ([8bc2aaa](https://github.com/LuanRT/YouTube.js/commit/8bc2aaa3587fcf79f69eedbc2bf422a4c6fa7eb1))
### Bug Fixes
* **CompactMovie:** Add missing import and remove unnecessary console.log ([#496](https://github.com/LuanRT/YouTube.js/issues/496)) ([c26972c](https://github.com/LuanRT/YouTube.js/commit/c26972c42a6368822ac254c00f1bbee5a1542486))
## [6.2.0](https://github.com/LuanRT/YouTube.js/compare/v6.1.0...v6.2.0) (2023-08-29)
### Features
* **Session:** Add fallback for session data retrieval ([#490](https://github.com/LuanRT/YouTube.js/issues/490)) ([10c15bf](https://github.com/LuanRT/YouTube.js/commit/10c15bfb9f131a2acea2f26ff3328993d8d8f4aa))
### Bug Fixes
* **Format:** Fix `is_original` always being `true` ([#492](https://github.com/LuanRT/YouTube.js/issues/492)) ([0412fa0](https://github.com/LuanRT/YouTube.js/commit/0412fa05ff1f00960b398c2f18d5ce39ce0cb864))
## [6.1.0](https://github.com/LuanRT/YouTube.js/compare/v6.0.2...v6.1.0) (2023-08-27)
### Features
* **parser:** Add `AlertWithButton` ([#486](https://github.com/LuanRT/YouTube.js/issues/486)) ([8b69587](https://github.com/LuanRT/YouTube.js/commit/8b6958778721ba274283f641779fb60bc6f42cd2))
* **parser:** Add `ChannelHeaderLinksView` ([#484](https://github.com/LuanRT/YouTube.js/issues/484)) ([ed7be2a](https://github.com/LuanRT/YouTube.js/commit/ed7be2a675cf1ec663e743e90db6260c97546739))
* **parser:** Add `CompactMovie` ([#487](https://github.com/LuanRT/YouTube.js/issues/487)) ([2eed172](https://github.com/LuanRT/YouTube.js/commit/2eed1726d5bde7648af09273cc14ab4a315cb23e))
## [6.0.2](https://github.com/LuanRT/YouTube.js/compare/v6.0.1...v6.0.2) (2023-08-24)
### Bug Fixes
* invalid set ids in dash manifest ([#480](https://github.com/LuanRT/YouTube.js/issues/480)) ([1c3ea2a](https://github.com/LuanRT/YouTube.js/commit/1c3ea2acd38652c6b40a0817a7836c672a776c4e))
## [6.0.1](https://github.com/LuanRT/YouTube.js/compare/v6.0.0...v6.0.1) (2023-08-22)
### Bug Fixes
* **SearchSubMenu:** Groups not being parsed due to a typo ([90be877](https://github.com/LuanRT/YouTube.js/commit/90be877d28e0ef013056eaeaa4f2765c91addd61))
## [6.0.0](https://github.com/LuanRT/YouTube.js/compare/v5.8.0...v6.0.0) (2023-08-18)
### ⚠ BREAKING CHANGES
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468))
### Features
* **MusicResponsiveListItem:** Detect non music tracks properly ([815e54b](https://github.com/LuanRT/YouTube.js/commit/815e54b854fcda3f5423231c8495ce1fb69d8237))
* **parser:** add `MusicMultiRowListItem` ([494ee87](https://github.com/LuanRT/YouTube.js/commit/494ee8776af0839d3ee2cca3d2fd836680cfdb9e))
* **Session:** Add `IOS` to `ClientType` enum ([22a38c0](https://github.com/LuanRT/YouTube.js/commit/22a38c0762499de74f0aeb3ef01332f893518b08))
* **VideoInfo:** support iOS client ([#467](https://github.com/LuanRT/YouTube.js/issues/467)) ([46fe18b](https://github.com/LuanRT/YouTube.js/commit/46fe18b763e0c943b24ea10fdf25456ab9ade709))
### Bug Fixes
* **Format:** Extracting audio language from captions ([#470](https://github.com/LuanRT/YouTube.js/issues/470)) ([31d27b1](https://github.com/LuanRT/YouTube.js/commit/31d27b1bca489ee0053d2783f1a956609845a901))
* **parser:** Allow any property in the `RawResponse` interface ([3bc53a8](https://github.com/LuanRT/YouTube.js/commit/3bc53a8c12e65b22f19a3e337641196b692a94db))
* **parser:** Logger logging `classdata` as `[Object object]` ([bf1510b](https://github.com/LuanRT/YouTube.js/commit/bf1510b235e3ee7d13d51f092babd1105c3d6b9f))
* **Playlist:** Only try extracting the subtitle for the first page ([#465](https://github.com/LuanRT/YouTube.js/issues/465)) ([e370116](https://github.com/LuanRT/YouTube.js/commit/e3701160928e9e959b88ca215c6b0a44c70ca6e6))
* **toDash:** Format grouping into AdaptationSets ([#462](https://github.com/LuanRT/YouTube.js/issues/462)) ([1ff3e1a](https://github.com/LuanRT/YouTube.js/commit/1ff3e1a440389e71055d4b201c29021ca5b39254))
### Performance Improvements
* Cleanup some unnecessary uses of `YTNode#key` and `Maybe` ([#463](https://github.com/LuanRT/YouTube.js/issues/463)) ([0dda97e](https://github.com/LuanRT/YouTube.js/commit/0dda97e0b03171de52d7f11a5abf78911e74cead))
### Code Refactoring
* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468)) ([87ed396](https://github.com/LuanRT/YouTube.js/commit/87ed3960ffa1c738b6f3b5acaf423647db4d367e))
## [5.8.0](https://github.com/LuanRT/YouTube.js/compare/v5.7.1...v5.8.0) (2023-07-30)
### Features
* **YouTube Playlist:** Add subtitle and fix author optionality ([#458](https://github.com/LuanRT/YouTube.js/issues/458)) ([0fa5a85](https://github.com/LuanRT/YouTube.js/commit/0fa5a859ae15a35266297079e3e34fd9f3a5ebf4))
## [5.7.1](https://github.com/LuanRT/YouTube.js/compare/v5.7.0...v5.7.1) (2023-07-25)
### Bug Fixes
* **SearchHeader:** remove console.log ([d91695a](https://github.com/LuanRT/YouTube.js/commit/d91695a9ec6c55445cbeedba4ace4ac1e0a72eee))
## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)
### Features
* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)
## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)
### Features
* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))
## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)
### Features
* **Format:** Populate audio language from captions when available ([#445](https://github.com/LuanRT/YouTube.js/issues/445)) ([bdd98a3](https://github.com/LuanRT/YouTube.js/commit/bdd98a3b9be39c11942043a300a6ebce9a15efc6))
* **parser:** Add `CommentsSimplebox` parser ([#442](https://github.com/LuanRT/YouTube.js/issues/442)) ([555d257](https://github.com/LuanRT/YouTube.js/commit/555d257459b76d7c0158e9c6b189a75a82b10faf))
* **parser:** Add `HashtagTile` ([#440](https://github.com/LuanRT/YouTube.js/issues/440)) ([ae2557d](https://github.com/LuanRT/YouTube.js/commit/ae2557d15c9df09bb92e0dc6191670d72b36631a))
* **parser:** add `MacroMarkersList` ([#444](https://github.com/LuanRT/YouTube.js/issues/444)) ([708c5f7](https://github.com/LuanRT/YouTube.js/commit/708c5f7394b4ea140836b9483848cb61b97ea1af))
* **parser:** Add `ShowMiniplayerCommand` ([#443](https://github.com/LuanRT/YouTube.js/issues/443)) ([a9cdbf7](https://github.com/LuanRT/YouTube.js/commit/a9cdbf7010e7b9b9cfde5db645d51bdad51006c5))
### Bug Fixes
* **package:** Bump Jinter to fix bad export order ([#439](https://github.com/LuanRT/YouTube.js/issues/439)) ([2aef678](https://github.com/LuanRT/YouTube.js/commit/2aef67876ec19118b37d3cecd429ccf8239989e0))
* **StructuredDescriptionContent:** `items` can also be a `HorizontalCardList` ([b50d1ef](https://github.com/LuanRT/YouTube.js/commit/b50d1ef67d81276864818de10c61b5a7980cbc1a))
## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)

View File

@@ -4,7 +4,6 @@ This page lists the collaborators who have contributed to the development and su
## [LuanRT](https://github.com/LuanRT)
[![Github Sponsors](https://img.shields.io/badge/donate-30363D?style=flat-square&logo=GitHub-Sponsors&logoColor=#white)](https://github.com/sponsors/LuanRT)
[![Ko-Fi](https://img.shields.io/badge/Ko--Fi-30363D?style=flat-square&logo=ko-fi)](https://ko-fi.com/luanrt)
Owner and maintainer.
@@ -15,4 +14,7 @@ Initial parser implementation, several bug fixes, major refactorings and general
Bug fixes and TypeScript support.
## [patrickkfkan](https://github.com/patrickkfkan)
Major refactorings, improved YouTube Music support, and bug fixes.
Major refactorings, improved YouTube Music support, and bug fixes.
## [Absidue](https://github.com/absidue)
Several bug fixes, new features & improved MPD support.

View File

@@ -32,7 +32,7 @@
<br>
<br>
<a href="https://serpapi.com" target="_blank">
<img width="80" alt="SerpApi" src="https://luanrt.is-a.dev/assets/img/serpapi.svg" />
<img width="80" alt="SerpApi" src="https://luanrt.github.io/assets/img/serpapi.svg" />
<br>
<sub>
API to get search engine results with ease.
@@ -788,7 +788,7 @@ We are immensely grateful to all the wonderful people who have contributed to th
## Contact
LuanRT - [@thesciencephile][twitter] - luan.lrt4@gmail.com
LuanRT - [@thesciencephile][twitter] - luanrt@thatsciencephile.com
Project Link: [https://github.com/LuanRT/YouTube.js][project]

View File

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

View File

@@ -1,21 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="/src/assets/style.css" />
<link rel="stylesheet" href="/src/assets/player.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube.js Example</title>
</head>
<body>
<form>
<input type="text" name="id" placeholder="Video ID or URL" />
<input type="submit" value="Play" />
</form>
<div id="loader"></div>
<div id="video_container">
<video id="video"></video>
<div class="loader" id="loader"></div>
<div id="video-container">
<div class="shaka-container" id="shaka-container" data-shaka-player-container>
<video class="videoel" id="videoel" data-shaka-player autoplay></video>
</div>
<h2 id="title"></h2>
<div id="metadata"></div>
<hr />
@@ -26,5 +28,4 @@
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -13,6 +13,6 @@
"vite": "^3.0.0"
},
"dependencies": {
"dashjs": "^4.4.0"
"shaka-player": "^4.3.8"
}
}

View File

@@ -0,0 +1,423 @@
@import url(https://fonts.googleapis.com/css?family=Material+Icons+Sharp);
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/roboto/v27/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype');
}
.shaka-container {
font-family: 'Roboto', sans-serif;
}
.shaka-container .shaka-bottom-controls {
width: 100%;
padding: 0;
padding-bottom: 0;
z-index: 1;
}
.shaka-container .shaka-bottom-controls {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.shaka-container .shaka-ad-controls {
-webkit-box-ordinal-group: 2;
-ms-flex-order: 1;
order: 1;
}
.shaka-container .shaka-spinner .shaka-spinner-path {
stroke: #ffffff;
}
.shaka-container .shaka-scrim-container {
margin: 0;
width: 100%;
height: 100%;
flex-shrink: 1;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: 0;
transition: opacity cubic-bezier(.4, 0, .6, 1) .6s;
background: linear-gradient(to top, hsla(0, 0%, 0%, 0.61), transparent 15%);
}
.shaka-container .shaka-play-button {
width: 100px;
height: 100px;
border-radius: 0;
background-color: transparent;
filter: invert();
box-shadow: none;
-webkit-box-ordinal-group: -3;
-ms-flex-order: -4;
order: -4;
}
.shaka-container .shaka-small-play-button {
-webkit-box-ordinal-group: -2;
-ms-flex-order: -3;
order: -3;
}
.shaka-container .shaka-mute-button {
-webkit-box-ordinal-group: -1;
-ms-flex-order: -2;
order: -2;
}
.shaka-container .shaka-controls-button-panel>* {
margin: 0;
padding: 3px 8px;
color: #EEE;
height: 40px;
}
.shaka-container .shaka-controls-button-panel>*:hover {
color: #FFF;
}
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container {
position: relative;
z-index: 10;
left: -1px;
-webkit-box-ordinal-group: 0;
-ms-flex-order: -1;
order: -1;
opacity: 0;
width: 0px;
-webkit-transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
height: 3px;
transition: width 0.2s cubic-bezier(0.4, 0, 1, 1);
padding: 0;
}
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:hover,
.shaka-container .shaka-controls-button-panel .shaka-volume-bar-container:focus {
display: block;
width: 50px;
opacity: 1;
padding: 0 6px;
}
.shaka-container .shaka-mute-button:hover+div {
opacity: 1;
width: 50px;
padding: 0 6px;
}
.shaka-container .shaka-current-time {
padding: 0 10px;
font-size: 12px;
}
.shaka-container .shaka-seek-bar-container {
height: 3px;
position: relative;
top: -1px;
border-radius: 0;
margin-bottom: 0;
}
.shaka-container .shaka-seek-bar-container .shaka-range-element {
opacity: 0;
transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.shaka-container .shaka-seek-bar-container:hover {
height: 5px;
top: 0;
cursor: pointer;
}
.shaka-container .shaka-seek-bar-container:hover .shaka-range-element {
opacity: 1;
cursor: pointer;
}
.shaka-container .shaka-seek-bar-container input[type=range]::-webkit-slider-thumb {
background: #FF0000;
cursor: pointer;
}
.shaka-container .shaka-seek-bar-container input[type=range]::-moz-range-thumb {
background: #FF0000;
cursor: pointer;
}
.shaka-container .shaka-seek-bar-container input[type=range]::-ms-thumb {
background: #FF0000;
cursor: pointer;
}
.shaka-container .shaka-video-container * {
font-family: 'Roboto', sans-serif;
}
.shaka-container .shaka-video-container .material-icons-round {
font-family: 'Material Icons Sharp';
}
.shaka-container .shaka-overflow-menu,
.shaka-container .shaka-settings-menu {
border-radius: 2px;
background: rgba(37, 37, 37, 0.9);
text-shadow: 0 0 2px rgb(0 0 0%);
-webkit-transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1);
-moz-user-select: none;
-ms-user-select: none;
animation: fade 0.3s;
-webkit-user-select: none;
right: 10px;
bottom: 50px;
padding: 0;
min-width: 200px;
}
@keyframes fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.shaka-container .shaka-settings-menu {
padding: 0 0 8px;
}
.shaka-container .shaka-settings-menu button {
font-size: 12px;
}
.shaka-container .shaka-settings-menu button span {
margin-left: 33px;
font-size: 13px;
}
.shaka-container .shaka-settings-menu button[aria-selected="true"] {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.shaka-container .shaka-settings-menu button[aria-selected="true"] span {
-webkit-box-ordinal-group: 3;
-ms-flex-order: 2;
order: 2;
margin-left: 0;
}
.shaka-container .shaka-settings-menu button[aria-selected="true"] i {
-webkit-box-ordinal-group: 2;
-ms-flex-order: 1;
order: 1;
font-size: 18px;
padding-left: 5px;
}
.shaka-container .shaka-overflow-menu button {
padding: 0;
}
.shaka-container .shaka-overflow-menu button i {
display: none;
}
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
cursor: default;
outline: none;
height: 40px;
-webkit-box-flex: 0;
-ms-flex: 0 0 100%;
flex: 0 0 100%;
}
.shaka-container .shaka-overflow-menu button .shaka-overflow-button-label span {
-ms-flex-negative: initial;
flex-shrink: initial;
padding-left: 15px;
font-size: 13px;
font-weight: 500;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.shaka-container .shaka-overflow-menu span+span {
color: #FFF;
font-weight: 400 !important;
font-size: 12px !important;
padding-right: 8px;
padding-left: 0 !important;
}
.shaka-container .shaka-overflow-menu span+span:after {
content: "navigate_next";
font-family: 'Material Icons Sharp';
font-size: 20px;
}
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span {
padding-right: 15px !important;
}
.shaka-container .shaka-overflow-menu .shaka-pip-button span+span:after {
content: "";
}
.shaka-container .shaka-back-to-overflow-button {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
font-size: 12px;
color: #eee;
height: 40px;
}
.shaka-container .shaka-back-to-overflow-button .material-icons-round {
font-size: 15px;
padding-right: 10px;
}
.shaka-container .shaka-back-to-overflow-button span {
margin-left: 3px !important;
}
.shaka-container .shaka-overflow-menu button:hover,
.shaka-container .shaka-settings-menu button:hover {
background-color: rgba(255, 255, 255, 0.1);
cursor: pointer;
}
.shaka-container .shaka-overflow-menu button:hover label,
.shaka-container .shaka-settings-menu button:hover label {
cursor: pointer;
}
.shaka-container .shaka-overflow-menu button,
.shaka-container .shaka-settings-menu button {
color: #EEE;
}
.shaka-container .shaka-captions-off {
color: #BFBFBF;
}
.shaka-container .shaka-overflow-menu-button {
font-size: 18px;
margin-right: 5px;
}
.shaka-container .shaka-fullscreen-button:hover {
font-size: 25px;
-webkit-transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1);
}
.shaka-container .shaka-overflow-menu,
.shaka-container .shaka-settings-menu {
border-radius: 10px;
}
@media (prefers-color-scheme: light) {
.shaka-container .shaka-overflow-menu,
.shaka-container .shaka-settings-menu {
background: rgba(255, 255, 255, 0.9);
}
.shaka-container .shaka-overflow-menu span+span,
.shaka-container .shaka-overflow-menu button,
.shaka-container .shaka-settings-menu button {
color: #000000;
}
}
@media (min-width: 800px) {
.shaka-container .shaka-controls-button-panel {
-webkit-box-ordinal-group: 3;
-ms-flex-order: 2;
order: 2;
height: 40px;
padding: 0 10px;
}
}
@media (max-width: 800px) {
.shaka-container .shaka-scrim-container {
background: rgba(0, 0, 0, 0.5);
}
.shaka-container .shaka-range-container {
margin: 0;
top: 0;
}
.shaka-container .shaka-mute-button {
display: none;
}
.shaka-container .shaka-overflow-menu,
.shaka-container .shaka-settings-menu {
bottom: 0;
top: 0;
left: 0;
right: 0;
width: 80%;
margin: 10px;
border-radius: 10px;
}
.shaka-container .shaka-overflow-menu button,
.shaka-container .shaka-settings-menu button {
width: 100%;
height: 40px;
padding: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.shaka-container .shaka-overflow-menu button span,
.shaka-container .shaka-settings-menu button span {
margin-left: 0;
padding-left: 15px;
}
}

View File

@@ -0,0 +1,135 @@
body {
display: flex;
flex-direction: column;
align-items: center;
background-color: #202020;
color: rgb(255, 255, 255);
line-height: 1.6;
font-family: Roboto, Arial, sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
hr {
width: 100%;
border: 1px solid transparent;
background-color: rgb(68, 68, 68);
}
form {
margin: 0.5rem 0;
display: none;
border-radius: 0.3rem;
background-color: rgb(68, 68, 68);
}
form input {
padding: 0.5rem;
border: none;
color: rgb(255, 255, 255);
}
form input[type="text"] {
background: transparent;
}
form input[type="text"]:focus {
outline: none;
}
form input[type="submit"] {
color: rgb(255, 255, 255);
background-color: rgba(0, 0, 0, 0.244);
cursor: pointer;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-background-clip: text;
-webkit-text-fill-color: #ffffff;
transition: background-color 5000s ease-in-out 0s;
}
#loader {
display: block;
border: 10px solid rgb(68, 68, 68);
border-top: 10px solid rgb(255, 255, 255);
border-radius: 50%;
width: 50px;
height: 50px;
align-self: center;
animation: spin 1s linear infinite;
margin: 0.5rem 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#shaka-container {
height: 40vw;
}
#video-container {
display: none;
flex-direction: column;
position: relative;
width: 70vw !important;
margin: 0.5rem 0;
}
#metadata {
display: flex;
flex-direction: row;
align-self: left;
margin: 0.5rem 0;
}
#metadata>#metadata-item {
margin: 0 0.3rem;
background-color: #ffffff;
color: rgba(0, 0, 0, 0.757);
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 0.3rem;
}
#video-container>#description {
align-self: left;
margin-left: 0.5rem;
font-size: medium;
}
video {
width: 100%;
height: 100%;
}
footer {
margin: 0.5rem 0;
}
@media screen and (max-width: 768px) {
video {
height: auto;
}
#shaka-container {
height: auto;
}
#video-container {
width: 100% !important;
}
}

View File

@@ -1,27 +1,27 @@
import './style.css';
import { Innertube, UniversalCache } from '../../../../bundle/browser';
import dashjs from 'dashjs';
const description = document.getElementById('description') as HTMLDivElement;
const form = document.querySelector('form') as HTMLFormElement;
// @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;
const description = document.getElementById('description') as HTMLDivElement;
const metadata = document.getElementById('metadata') as HTMLDivElement;
const loader = document.getElementById('loader') as HTMLDivElement;
const video = document.getElementById('video') as HTMLVideoElement;
const video_container = document.getElementById('video_container') as HTMLDivElement;
const form = document.querySelector('form') as HTMLFormElement;
async function main() {
const yt = await Innertube.create({
generate_session_locally: true,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
// url
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
// transform the url for use with our proxy
// Transform the url for use with our proxy.
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
@@ -32,13 +32,15 @@ async function main() {
? input.headers
: new Headers();
// now serialize the headers
// Now serialize the headers.
url.searchParams.set('__headers', JSON.stringify([...headers]));
// @ts-ignore
input.duplex = 'half';
if (input instanceof Request) {
// @ts-ignore
input.duplex = 'half';
}
// copy over the request
// Copy over the request.
const request = new Request(
url,
input instanceof Request ? input : undefined,
@@ -46,7 +48,6 @@ async function main() {
headers.delete('user-agent');
// fetch the url
return fetch(request, init ? {
...init,
headers
@@ -60,45 +61,46 @@ async function main() {
form.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
form.style.display = 'block';
showUI(false);
showUI({ hidePlayer: true });
let player: dashjs.MediaPlayerClass | undefined;
let player: shaka.Player | undefined;
let ui: shaka.ui.Overlay | undefined;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (player) {
player.reset();
player.destroy();
}
hideUI();
let video_id;
let videoId;
const video_id_or_url = document.querySelector<HTMLInputElement>('input[type=text]')?.value;
const videoIdOrURL = document.querySelector<HTMLInputElement>('input[type=text]')?.value;
if (!video_id_or_url) {
if (!videoIdOrURL) {
title.textContent = 'No video id or URL provided';
showUI(false);
showUI({ hidePlayer: true });
return;
}
try {
if (video_id_or_url.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
const endpoint = await yt.resolveURL(video_id_or_url);
if (videoIdOrURL.match(/(http|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])/)) {
const endpoint = await yt.resolveURL(videoIdOrURL);
if (!endpoint.payload.videoId) {
title.textContent = 'Could not resolve URL';
showUI(false);
showUI({ hidePlayer: true });
return;
}
video_id = endpoint.payload.videoId;
videoId = endpoint.payload.videoId;
} else {
video_id = video_id_or_url;
videoId = videoIdOrURL;
}
const info = await yt.getInfo(video_id);
const info = await yt.getInfo(videoId);
title.textContent = info.basic_info.title || null;
description.innerHTML = info.secondary_info?.description.toHTML() || '';
@@ -106,51 +108,172 @@ async function main() {
document.title = info.basic_info.title || '';
metadata!.innerHTML = '';
metadata!.innerHTML += `<div class="metadata_item">${info.primary_info?.published.toHTML()}</div>`;
metadata!.innerHTML += `<div class="metadata_item">${info.primary_info?.view_count.toHTML()}</div>`;
metadata!.innerHTML += `<div class="metadata_item">${info.basic_info.like_count} likes</div>`;
metadata.innerHTML = '';
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.published.toHTML()}</div>`;
metadata.innerHTML += `<div id="metadata-item">${info.primary_info?.view_count.toHTML()}</div>`;
metadata.innerHTML += `<div id="metadata-item">${info.basic_info.like_count} likes</div>`;
showUI(true);
showUI({ hidePlayer: false });
const dash = await info.toDash((url) => {
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
return url;
});
const dash = await info.toDash();
const uri = 'data:application/dash+xml;charset=utf-8;base64,' + btoa(dash);
// create and append video element
const video_element = document.querySelector('video') as HTMLVideoElement;
video_element.setAttribute('controls', 'true');
video_element.poster = info.basic_info.thumbnail![0].url;
// use dash.js to parse the manifest
if (player) {
player.destroy();
await player.destroy();
player = undefined;
}
player = dashjs.MediaPlayer().create();
player.initialize(video_element, uri, true);
player.setInitialMediaSettingsFor('audio', { lang: 'en-US' });
if (ui) {
ui.destroy();
ui = undefined;
}
const videoEl = document.getElementById('videoel') as HTMLVideoElement;
const shakaContainer = document.getElementById('shaka-container') as HTMLDivElement;
shakaContainer
.querySelectorAll("div")
.forEach(node => node.remove());
shaka.polyfill.installAll();
if (shaka.Player.isBrowserSupported()) {
videoEl.poster = info.basic_info.thumbnail![0].url;
player = new shaka.Player(videoEl);
ui = new shaka.ui.Overlay(player, shakaContainer, videoEl);
const config = {
seekBarColors: {
base: 'rgba(255,255,255,.2)',
buffered: 'rgba(255,255,255,.4)',
played: 'rgb(255,0,0)',
},
fadeDelay: 0,
};
ui.configure(config);
const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button');
if (overflowMenuButton) {
overflowMenuButton.innerHTML = 'settings';
}
const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button .material-icons-round');
if (backToOverflowButton) {
backToOverflowButton.innerHTML = 'arrow_back_ios_new';
}
player.configure({
streaming: {
bufferingGoal: 180,
rebufferingGoal: 0.02,
bufferBehind: 300
}
});
player.getNetworkingEngine()?.registerRequestFilter((_type: any, request: any) => {
const uri = request.uris[0];
const url = new URL(uri);
const headers = request.headers;
if (url.host.endsWith(".googlevideo.com") || headers.Range) {
url.searchParams.set('__host', url.host);
url.host = 'localhost:8080';
url.protocol = 'http';
}
request.method = 'POST';
// protobuf - { 15: 0 }
request.body = new Uint8Array([120, 0]);
if (url.pathname === "/videoplayback") {
if (headers.Range) {
request.headers = {};
url.searchParams.set("range", headers.Range.split("=")[1]);
url.searchParams.set("alr", "yes");
}
}
request.uris[0] = url.toString();
});
// The UTF-8 characters "h", "t", "t", and "p".
const HTTP_IN_HEX = 0x68747470;
const RequestType = shaka.net.NetworkingEngine.RequestType;
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;
if (type == RequestType.MANIFEST) {
retry_parameters = player!.getConfiguration().manifest.retryParameters;
} else if (type == RequestType.SEGMENT) {
retry_parameters = player!.getConfiguration().streaming.retryParameters;
} else if (type == RequestType.LICENSE) {
retry_parameters = player!.getConfiguration().drm.retryParameters;
} else {
retry_parameters = shaka.net.NetworkingEngine.defaultRetryParameters();
}
// Make another request for the redirect URL.
const uris = [response_as_string];
const redirect_request = shaka.net.NetworkingEngine.makeRequest(uris, retry_parameters);
const request_operation = player!.getNetworkingEngine()!.request(type, redirect_request);
const redirect_response = await request_operation.promise;
// Modify the original response to contain the results of the redirect
// response.
response.data = redirect_response.data;
response.headers = redirect_response.headers;
response.uri = redirect_response.uri;
});
try {
await player.load(uri);
} catch (e) {
console.error('Could not load manifest', e);
}
} else {
console.error('Browser not supported!');
}
} catch (error) {
title.textContent = 'An error occurred (see console)';
showUI(false);
showUI({ hidePlayer: true });
console.error(error);
}
});
}
function showUI(with_video = true) {
loader.style.display = 'none';
video.style.display = with_video ? 'block' : 'none';
function showUI(args: { hidePlayer?: boolean } = {
hidePlayer: true,
}) {
const ytplayer = document.getElementById('shaka-container') as HTMLDivElement;
ytplayer.style.display = args.hidePlayer ? 'none' : 'block';
const video_container = document.getElementById('video-container') as HTMLDivElement;
video_container.animate({ opacity: [0, 1] }, { duration: 300, easing: 'ease-in-out' });
video_container.style.display = 'block';
loader.style.display = 'none';
}
function hideUI() {
const video_container = document.getElementById('video-container') as HTMLDivElement;
video_container.style.display = 'none';
loader.style.display = 'block';
}

View File

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

5971
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "5.4.0",
"version": "6.3.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -56,7 +56,8 @@
"Wykerd (https://github.com/wykerd/)",
"MasterOfBob777 (https://github.com/MasterOfBob777)",
"patrickkfkan (https://github.com/patrickkfkan)",
"akkadaska (https://github.com/akkadaska)"
"akkadaska (https://github.com/akkadaska)",
"Absidue (https://github.com/absidue)"
],
"directories": {
"test": "./test",
@@ -68,10 +69,10 @@
"lint": "npx eslint ./src",
"lint:fix": "npx eslint --fix ./src",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod",
"build:parser-map": "node ./scripts/build-parser-map.cjs",
"build:parser-map": "node ./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 tsc",
"build:deno": "npx cpy ./src ./deno && npx cpy ./package.json ./deno && npx replace \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'linkedom';\" \"'https://esm.sh/linkedom';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r && npx replace \"new Jinter.default\" \"new Jinter\" ./deno -r",
"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:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
@@ -84,12 +85,12 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.0.0",
"linkedom": "^0.14.12",
"jintr": "^1.1.0",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},
"devDependencies": {
"@types/glob": "^8.1.0",
"@types/jest": "^28.1.7",
"@types/node": "^17.0.45",
"@typescript-eslint/eslint-plugin": "^5.30.6",

View File

@@ -1,10 +1,13 @@
const glob = require('glob');
const fs = require('fs');
const path = require('path');
import glob from "glob";
import path from 'path';
import fs from 'fs';
import url from 'url';
const import_list = [];
const misc_imports = [];
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
.forEach((file) => {
// Trim path

View File

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

48
scripts/get-agents.mjs Normal file
View File

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

View File

@@ -19,7 +19,7 @@ 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 Proto from './proto/index.js';
import * as Proto from './proto/index.js';
import * as Constants from './utils/Constants.js';
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js';
@@ -38,11 +38,11 @@ import { GetUnseenCountEndpoint } from './core/endpoints/notification/index.js';
import type { ApiResponse } from './core/Actions.js';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js';
import type { INextRequest } from './types/index.js';
import type { DownloadOptions, FormatOptions } from './utils/FormatUtils.js';
import type { DownloadOptions, FormatOptions } from './types/FormatUtils.js';
export type InnertubeConfig = SessionOptions;
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS'
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';

View File

@@ -3,7 +3,7 @@ import EventEmitterLike from '../utils/EventEmitterLike.js';
import Actions from './Actions.js';
import Player from './Player.js';
import Proto from '../proto/index.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';
@@ -16,6 +16,7 @@ export enum ClientType {
WEB = 'WEB',
KIDS = 'WEB_KIDS',
MUSIC = 'WEB_REMIX',
IOS = 'iOS',
ANDROID = 'ANDROID',
ANDROID_MUSIC = 'ANDROID_MUSIC',
ANDROID_CREATOR = 'ANDROID_CREATOR',
@@ -62,6 +63,7 @@ export interface Context {
user: {
enableSafetyMode: boolean;
lockedSafetyMode: boolean;
onBehalfOfUser?: string;
};
thirdParty?: {
embedUrl: string;
@@ -83,6 +85,10 @@ export interface SessionOptions {
* Only works if you are signed in with cookies.
*/
account_index?: number;
/**
* Specify the Page ID of the YouTube profile/channel to use, if the logged-in account has multiple profiles.
*/
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.
@@ -192,7 +198,8 @@ export default class Session extends EventEmitterLike {
options.device_category,
options.client_type,
options.timezone,
options.fetch
options.fetch,
options.on_behalf_of_user
);
return new Session(
@@ -212,14 +219,22 @@ export default class Session extends EventEmitterLike {
device_category: DeviceCategory = 'desktop',
client_name: ClientType = ClientType.WEB,
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
fetch: FetchFunction = Platform.shim.fetch
fetch: FetchFunction = Platform.shim.fetch,
on_behalf_of_user?: 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 };
if (generate_session_locally) {
session_data = this.#generateSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data });
session_data = this.#generateSessionData(session_args);
} else {
session_data = await this.#retrieveSessionData({ lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data }, fetch);
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);
}
}
return { ...session_data, account_index };
@@ -233,6 +248,7 @@ export default class Session extends EventEmitterLike {
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);
@@ -292,7 +308,8 @@ export default class Session extends EventEmitterLike {
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
}
};
@@ -307,6 +324,7 @@ export default class Session extends EventEmitterLike {
client_name: string;
enable_safety_mode: boolean;
visitor_data: string;
on_behalf_of_user?: string;
}): SessionData {
let visitor_id = generateRandomString(11);
@@ -339,7 +357,8 @@ export default class Session extends EventEmitterLike {
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
}
};

View File

@@ -18,7 +18,7 @@ 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 Proto from '../../proto/index.js';
import * as Proto from '../../proto/index.js';
import type { ObservedArray, YTNode } from '../../parser/helpers.js';
import type { MusicSearchFilters } from '../../types/index.js';
@@ -329,7 +329,7 @@ export default class Music {
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
if (page.contents.item().type === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text.toString(), video_id);
const section_list = page.contents.item().as(SectionList).contents;

View File

@@ -1,4 +1,4 @@
import Proto from '../../proto/index.js';
import * as Proto from '../../proto/index.js';
import * as Constants from '../../utils/Constants.js';
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js';

View File

@@ -3,7 +3,7 @@ import Analytics from '../../parser/youtube/Analytics.js';
import Settings from '../../parser/youtube/Settings.js';
import TimeWatched from '../../parser/youtube/TimeWatched.js';
import Proto from '../../proto/index.js';
import * as Proto from '../../proto/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import { Account, BrowseEndpoint, Channel } from '../endpoints/index.js';

View File

@@ -1,4 +1,4 @@
import Proto from '../../proto/index.js';
import * as Proto from '../../proto/index.js';
import type Actions from '../Actions.js';
import type { ApiResponse } from '../Actions.js';

View File

@@ -1,12 +1,15 @@
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 '../../utils/FormatUtils.js';
import FormatUtils from '../../utils/FormatUtils.js';
import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../types/FormatUtils.js';
import * as FormatUtils from '../../utils/FormatUtils.js';
import { InnertubeError } from '../../utils/Utils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { INextResponse, 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';
export default class MediaInfo {
#page: [IPlayerResponse, INextResponse?];
@@ -37,10 +40,32 @@ export default class MediaInfo {
* Generates a DASH manifest from the streaming data.
* @param url_transformer - Function to transform the URLs.
* @param format_filter - Function to filter the formats.
* @param options - Additional options to customise the manifest generation
* @returns DASH manifest
*/
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
let storyboards;
if (options.include_thumbnails && this.#page[0].storyboards?.is(PlayerStoryboardSpec)) {
storyboards = this.#page[0].storyboards;
}
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards);
}
/**
* Get a cleaned up representation of the adaptive_formats
*/
getStreamingInfo(url_transformer?: URLTransformer, format_filter?: FormatFilter) {
return getStreamingInfo(
this.streaming_data,
url_transformer,
format_filter,
this.cpn,
this.#actions.session.player,
this.#actions,
this.#page[0].storyboards?.is(PlayerStoryboardSpec) ? this.#page[0].storyboards : undefined
);
}
/**

View File

@@ -310,7 +310,7 @@ const example_data = {
// The first argument is the name of the class, the second is the data you have for the node.
// It will return a class that extends YTNode.
const Example = Generator.YTNodeGenerator.generateRuntimeClass('Example', example_data);
const Example = Generator.generateRuntimeClass('Example', example_data);
// You may now use this class as you would any other node.
const example = new Example(example_data);

View File

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

View File

@@ -15,26 +15,20 @@ export default class Button extends YTNode {
constructor(data: RawNode) {
super();
if (Reflect.has(data, 'text')) {
if (Reflect.has(data, 'text'))
this.text = new Text(data.text).toString();
}
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label')) {
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label'))
this.label = data.accessibility.label;
}
if (Reflect.has(data, 'tooltip')) {
if (Reflect.has(data, 'tooltip'))
this.tooltip = data.tooltip;
}
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType'))
this.icon_type = data.icon.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
if (Reflect.has(data, 'isDisabled'))
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}

View File

@@ -2,6 +2,7 @@ import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import Button from './Button.js';
import ChannelHeaderLinks from './ChannelHeaderLinks.js';
import ChannelHeaderLinksView from './ChannelHeaderLinksView.js';
import SubscribeButton from './SubscribeButton.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
@@ -18,7 +19,7 @@ export default class C4TabbedHeader extends YTNode {
videos_count?: Text;
sponsor_button?: Button | null;
subscribe_button?: SubscribeButton | Button | null;
header_links?: ChannelHeaderLinks | null;
header_links?: ChannelHeaderLinks | ChannelHeaderLinksView | null;
channel_handle?: Text;
channel_id?: string;
@@ -58,7 +59,7 @@ export default class C4TabbedHeader extends YTNode {
}
if (Reflect.has(data, 'headerLinks')) {
this.header_links = Parser.parseItem(data.headerLinks, ChannelHeaderLinks);
this.header_links = Parser.parseItem(data.headerLinks, [ ChannelHeaderLinks, ChannelHeaderLinksView ]);
}
if (Reflect.has(data, 'channelHandleText')) {

View File

@@ -0,0 +1,22 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
export default class ChannelHeaderLinksView extends YTNode {
static type = 'ChannelHeaderLinksView';
first_link?: Text;
more?: Text;
constructor(data: RawNode) {
super();
if (Reflect.has(data, 'firstLink')) {
this.first_link = Text.fromAttributed(data.firstLink);
}
if (Reflect.has(data, 'more')) {
this.more = Text.fromAttributed(data.more);
}
}
}

View File

@@ -14,6 +14,7 @@ export default class ChannelMetadata extends YTNode {
is_family_safe: boolean;
keywords: string[];
avatar: Thumbnail[];
music_artist_name?: string;
available_countries: string[];
android_deep_link: string;
android_appindexing_link: string;
@@ -30,6 +31,8 @@ export default class ChannelMetadata extends YTNode {
this.is_family_safe = data.isFamilySafe;
this.keywords = data.keywords;
this.avatar = Thumbnail.fromResponse(data.avatar);
// Can be an empty string sometimes, so we need the extra length check
this.music_artist_name = typeof data.musicArtistName === 'string' && data.musicArtistName.length > 0 ? data.musicArtistName : undefined;
this.available_countries = data.availableCountryCodes;
this.android_deep_link = data.androidDeepLink;
this.android_appindexing_link = data.androidAppindexingLink;

View File

@@ -0,0 +1,57 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import Parser from '../index.js';
import Author from './misc/Author.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import Menu from './menus/Menu.js';
import { timeToSeconds } from '../../utils/Utils.js';
export default class CompactMovie extends YTNode {
static type = 'CompactMovie';
id: string;
title: Text;
top_metadata_items: Text;
thumbnails: Thumbnail[];
thumbnail_overlays: ObservedArray<YTNode>;
author: Author;
duration: {
text: string;
seconds: number;
};
endpoint: NavigationEndpoint;
badges: ObservedArray<YTNode>;
use_vertical_poster: boolean;
menu: Menu | null;
constructor(data: RawNode) {
super();
const overlay_time_status = data.thumbnailOverlays
.find((overlay: RawNode) => overlay.thumbnailOverlayTimeStatusRenderer)
?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';
this.id = data.videoId;
this.title = new Text(data.title);
this.top_metadata_items = new Text(data.topMetadataItems);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.author = new Author(data.shortBylineText);
const durationText = data.lengthText ? new Text(data.lengthText).toString() : new Text(overlay_time_status).toString();
this.duration = {
text: durationText,
seconds: timeToSeconds(durationText)
};
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.badges = Parser.parseArray(data.badges);
this.use_vertical_poster = data.useVerticalPoster;
this.menu = Parser.parseItem(data.menu, Menu);
}
}

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 ContentPreviewImageView extends YTNode {
static type = 'ContentPreviewImageView';
image: Thumbnail[];
style: string;
constructor(data: RawNode) {
super();
this.image = data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width);
this.style = data.style;
}
}

View File

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

View File

@@ -2,6 +2,7 @@ import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ContinuationItem from './ContinuationItem.js';
import EngagementPanelTitleHeader from './EngagementPanelTitleHeader.js';
import MacroMarkersList from './MacroMarkersList.js';
import SectionList from './SectionList.js';
import StructuredDescriptionContent from './StructuredDescriptionContent.js';
@@ -9,7 +10,7 @@ export default class EngagementPanelSectionList extends YTNode {
static type = 'EngagementPanelSectionList';
header: EngagementPanelTitleHeader | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | MacroMarkersList | null;
target_id?: string;
panel_identifier?: string;
visibility?: string;
@@ -17,7 +18,7 @@ export default class EngagementPanelSectionList extends YTNode {
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent ]);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent, MacroMarkersList ]);
this.panel_identifier = data.panelIdentifier;
this.target_id = data.targetId;
this.visibility = data.visibility;

View File

@@ -1,6 +1,6 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import Parser from '../parser.js';
import * as Parser from '../parser.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

View File

@@ -1,4 +1,4 @@
import Parser from '../parser.js';
import * as Parser from '../parser.js';
import GuideEntry from './GuideEntry.js';
import type { RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

View File

@@ -1,4 +1,4 @@
import Parser from '../parser.js';
import * as Parser from '../parser.js';
import type { RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

View File

@@ -1,5 +1,5 @@
import Text from './misc/Text.js';
import Parser from '../parser.js';
import * as Parser from '../parser.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

View File

@@ -0,0 +1,28 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Thumbnail } from '../misc.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
export default class HashtagTile extends YTNode {
static type = 'HashtagTile';
hashtag: Text;
hashtag_info_text: Text;
hashtag_thumbnail: Thumbnail[];
endpoint: NavigationEndpoint;
hashtag_background_color: number;
hashtag_video_count: Text;
hashtag_channel_count: Text;
constructor(data: RawNode) {
super();
this.hashtag = new Text(data.hashtag);
this.hashtag_info_text = new Text(data.hashtagInfoText);
this.hashtag_thumbnail = Thumbnail.fromResponse(data.hashtagThumbnail);
this.endpoint = new NavigationEndpoint(data.onTapCommand);
this.hashtag_background_color = data.hashtagBackgroundColor;
this.hashtag_video_count = new Text(data.hashtagVideoCount);
this.hashtag_channel_count = new Text(data.hashtagChannelCount);
}
}

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
export default class IncludingResultsFor extends YTNode {
static type = 'IncludingResultsFor';
including_results_for: Text;
corrected_query: Text;
corrected_query_endpoint: NavigationEndpoint;
search_only_for?: Text;
original_query?: Text;
original_query_endpoint?: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.including_results_for = new Text(data.includingResultsFor);
this.corrected_query = new Text(data.correctedQuery);
this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
this.search_only_for = Reflect.has(data, 'searchOnlyFor') ? new Text(data.searchOnlyFor) : undefined;
this.original_query = Reflect.has(data, 'originalQuery') ? new Text(data.originalQuery) : undefined;
this.original_query_endpoint = Reflect.has(data, 'originalQueryEndpoint') ? new NavigationEndpoint(data.originalQueryEndpoint) : undefined;
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
export default class MacroMarkersInfoItem extends YTNode {
static type = 'MacroMarkersInfoItem';
info_text: Text;
menu: Menu | null;
constructor(data: RawNode) {
super();
this.info_text = new Text(data.infoText);
this.menu = Parser.parseItem(data.menu, Menu);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';
import MacroMarkersInfoItem from './MacroMarkersInfoItem.js';
import MacroMarkersListItem from './MacroMarkersListItem.js';
export default class MacroMarkersList extends YTNode {
static type = 'MacroMarkersList';
contents: ObservedArray<MacroMarkersInfoItem | MacroMarkersListItem>;
sync_button_label: Text;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents, [ MacroMarkersInfoItem, MacroMarkersListItem ]);
this.sync_button_label = new Text(data.syncButtonLabel);
}
}

View File

@@ -2,6 +2,7 @@ import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import MusicCarouselShelfBasicHeader from './MusicCarouselShelfBasicHeader.js';
import MusicMultiRowListItem from './MusicMultiRowListItem.js';
import MusicNavigationButton from './MusicNavigationButton.js';
import MusicResponsiveListItem from './MusicResponsiveListItem.js';
import MusicTwoRowItem from './MusicTwoRowItem.js';
@@ -10,13 +11,13 @@ export default class MusicCarouselShelf extends YTNode {
static type = 'MusicCarouselShelf';
header: MusicCarouselShelfBasicHeader | null;
contents: ObservedArray<MusicTwoRowItem | MusicResponsiveListItem | MusicNavigationButton>;
contents: ObservedArray<MusicTwoRowItem | MusicResponsiveListItem | MusicMultiRowListItem | MusicNavigationButton>;
num_items_per_column?: number;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, MusicCarouselShelfBasicHeader);
this.contents = Parser.parseArray(data.contents, [ MusicTwoRowItem, MusicResponsiveListItem, MusicNavigationButton ]);
this.contents = Parser.parseArray(data.contents, [ MusicTwoRowItem, MusicResponsiveListItem, MusicMultiRowListItem, MusicNavigationButton ]);
if (Reflect.has(data, 'numItemsPerColumn')) {
this.num_items_per_column = parseInt(data.numItemsPerColumn);

View File

@@ -0,0 +1,44 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';
import Menu from './menus/Menu.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import MusicThumbnail from './MusicThumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class MusicMultiRowListItem extends YTNode {
static type = 'MusicMultiRowListItem';
thumbnail: MusicThumbnail | null;
overlay: MusicItemThumbnailOverlay | null;
on_tap: NavigationEndpoint;
menu: Menu | null;
subtitle: Text;
title: Text;
second_title?: Text;
description?: Text;
display_style?: string;
constructor(data: RawNode) {
super();
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay);
this.on_tap = new NavigationEndpoint(data.onTap);
this.menu = Parser.parseItem(data.menu, Menu);
this.subtitle = new Text(data.subtitle);
this.title = new Text(data.title);
if (Reflect.has(data, 'secondTitle')) {
this.second_title = new Text(data.secondTitle);
}
if (Reflect.has(data, 'description')) {
this.description = new Text(data.description);
}
if (Reflect.has(data, 'displayStyle')) {
this.display_style = data.displayStyle;
}
}
}

View File

@@ -1,5 +1,10 @@
// TODO: Clean up and refactor this.
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 Parser from '../index.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import MusicResponsiveListItemFixedColumn from './MusicResponsiveListItemFixedColumn.js';
@@ -9,11 +14,6 @@ import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import { isTextRun, timeToSeconds } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export default class MusicResponsiveListItem extends YTNode {
static type = 'MusicResponsiveListItem';
@@ -24,8 +24,8 @@ export default class MusicResponsiveListItem extends YTNode {
playlist_set_video_id: string;
};
endpoint: NavigationEndpoint | null;
item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'video' | 'song' | 'endpoint' | 'unknown' | undefined;
endpoint?: NavigationEndpoint;
item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'non_music_track' | 'video' | 'song' | 'endpoint' | 'unknown' | undefined;
index?: Text;
thumbnail?: MusicThumbnail | null;
badges;
@@ -82,9 +82,21 @@ export default class MusicResponsiveListItem extends YTNode {
playlist_set_video_id: data?.playlistItemData?.playlistSetVideoId || null
};
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : null;
if (Reflect.has(data, 'navigationEndpoint')) {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
}
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
let page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
if (!page_type) {
const is_non_music_track = this.flex_columns.find(
(col) => col.title.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE'
);
if (is_non_music_track) {
page_type = 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE';
}
}
switch (page_type) {
case 'MUSIC_PAGE_TYPE_ALBUM':
@@ -104,27 +116,41 @@ export default class MusicResponsiveListItem extends YTNode {
this.item_type = 'library_artist';
this.#parseLibraryArtist();
break;
case 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE':
this.item_type = 'non_music_track';
this.#parseNonMusicTrack();
break;
default:
if (this.flex_columns[1]) {
this.#parseVideoOrSong();
} else {
this.#parseOther();
}
break;
}
if (data.index) {
if (Reflect.has(data, 'index')) {
this.index = new Text(data.index);
}
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
this.badges = Parser.parseArray(data.badges);
this.menu = Parser.parseItem(data.menu, Menu);
this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay);
if (Reflect.has(data, 'thumbnail')) {
this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
}
if (Reflect.has(data, 'badges')) {
this.badges = Parser.parseArray(data.badges);
}
if (Reflect.has(data, 'menu')) {
this.menu = Parser.parseItem(data.menu, Menu);
}
if (Reflect.has(data, 'overlay')) {
this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay);
}
}
#parseOther() {
this.title = this.flex_columns.first().key('title').instanceof(Text).toString();
this.title = this.flex_columns.first().title.toString();
if (this.endpoint) {
this.item_type = 'endpoint';
@@ -134,7 +160,7 @@ export default class MusicResponsiveListItem extends YTNode {
}
#parseVideoOrSong() {
const is_video = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.some((run) => run.text.match(/(.*?) views/));
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();
@@ -146,10 +172,10 @@ export default class MusicResponsiveListItem extends YTNode {
#parseSong() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns.first().key('title').instanceof(Text).toString();
this.title = this.flex_columns.first().title.toString();
const duration_text = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.key('title').instanceof(Text)?.toString();
const duration_text = this.flex_columns.at(1)?.title.runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title?.toString();
if (duration_text) {
this.duration = {
@@ -159,12 +185,12 @@ export default class MusicResponsiveListItem extends YTNode {
}
const album_run =
this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find(
this.flex_columns.at(1)?.title.runs?.find(
(run) =>
(isTextRun(run) && run.endpoint) &&
run.endpoint.payload.browseId.startsWith('MPR')
) ||
this.flex_columns.at(2)?.key('title').instanceof(Text).runs?.find(
this.flex_columns.at(2)?.title.runs?.find(
(run) =>
(isTextRun(run) && run.endpoint) &&
run.endpoint.payload.browseId.startsWith('MPR')
@@ -178,7 +204,7 @@ export default class MusicResponsiveListItem extends YTNode {
};
}
const artist_runs = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.filter(
const artist_runs = this.flex_columns.at(1)?.title.runs?.filter(
(run) => (isTextRun(run) && run.endpoint) && run.endpoint.payload.browseId.startsWith('UC')
);
@@ -193,10 +219,10 @@ export default class MusicResponsiveListItem extends YTNode {
#parseVideo() {
this.id = this.#playlist_item_data.video_id;
this.title = this.flex_columns.first().key('title').instanceof(Text).toString();
this.views = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.toString();
this.title = this.flex_columns.first().title.toString();
this.views = this.flex_columns.at(1)?.title.runs?.find((run) => run.text.match(/(.*?) views/))?.toString();
const author_runs = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.filter(
const author_runs = this.flex_columns.at(1)?.title.runs?.filter(
(run) =>
(isTextRun(run) && run.endpoint) &&
run.endpoint.payload.browseId.startsWith('UC')
@@ -212,8 +238,8 @@ export default class MusicResponsiveListItem extends YTNode {
});
}
const duration_text = this.flex_columns[1].key('title').instanceof(Text).runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.key('title').instanceof(Text).runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text;
const duration_text = this.flex_columns[1].title.runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title.runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text;
if (duration_text) {
this.duration = {
@@ -225,22 +251,27 @@ export default class MusicResponsiveListItem extends YTNode {
#parseArtist() {
this.id = this.endpoint?.payload?.browseId;
this.name = this.flex_columns.first().key('title').instanceof(Text).toString();
this.subtitle = this.flex_columns.at(1)?.key('title').instanceof(Text);
this.name = this.flex_columns.first().title.toString();
this.subtitle = this.flex_columns.at(1)?.title;
this.subscribers = this.subtitle?.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
}
#parseLibraryArtist() {
this.name = this.flex_columns.first().key('title').instanceof(Text).toString();
this.subtitle = this.flex_columns.at(1)?.key('title').instanceof(Text);
this.name = this.flex_columns.first().title.toString();
this.subtitle = this.flex_columns.at(1)?.title;
this.song_count = this.subtitle?.runs?.find((run) => (/^\d+(,\d+)? songs?$/i).test(run.text))?.text || '';
}
#parseNonMusicTrack() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns.first().title.toString();
}
#parseAlbum() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
const author_run = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find(
const author_run = this.flex_columns.at(1)?.title.runs?.find(
(run) =>
(isTextRun(run) && run.endpoint) &&
run.endpoint.payload.browseId.startsWith('UC')
@@ -254,7 +285,7 @@ export default class MusicResponsiveListItem extends YTNode {
};
}
this.year = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find(
this.year = this.flex_columns.at(1)?.title.runs?.find(
(run) => (/^[12][0-9]{3}$/).test(run.text)
)?.text;
}
@@ -263,12 +294,12 @@ export default class MusicResponsiveListItem extends YTNode {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
const item_count_run = this.flex_columns.at(1)?.key('title')
.instanceof(Text).runs?.find((run) => run.text.match(/\d+ (song|songs)/));
const item_count_run = this.flex_columns.at(1)?.title
.runs?.find((run) => run.text.match(/\d+ (song|songs)/));
this.item_count = item_count_run ? item_count_run.text : undefined;
const author_run = this.flex_columns.at(1)?.key('title').instanceof(Text).runs?.find(
const author_run = this.flex_columns.at(1)?.title.runs?.find(
(run) =>
(isTextRun(run) && run.endpoint) &&
run.endpoint.payload.browseId.startsWith('UC')

View File

@@ -4,12 +4,14 @@ 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 OpenPopupAction from './actions/OpenPopupAction.js';
export default class NavigationEndpoint extends YTNode {
static type = 'NavigationEndpoint';
payload;
dialog?: CreatePlaylistDialog | YTNode | null;
open_popup?: OpenPopupAction | null;
metadata: {
url?: string;
@@ -24,6 +26,9 @@ export default class NavigationEndpoint extends YTNode {
if (Reflect.has(data || {}, 'innertubeCommand'))
data = data.innertubeCommand;
if (Reflect.has(data || {}, 'openPopupAction'))
this.open_popup = new OpenPopupAction(data.openPopupAction);
const name = Object.keys(data || {})
.find((item) =>
item.endsWith('Endpoint') ||
@@ -36,6 +41,7 @@ export default class NavigationEndpoint extends YTNode {
this.dialog = Parser.parseItem(this.payload.dialog || this.payload.content);
}
if (data?.serviceEndpoint) {
data = data.serviceEndpoint;
}
@@ -85,9 +91,9 @@ export default class NavigationEndpoint extends YTNode {
}
}
call<T extends IParsedResponse>(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<T>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ApiResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
call<T extends IParsedResponse>(actions: Actions, args: { [key: string]: any; parse: true }): Promise<T>;
call(actions: Actions, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
call(actions: Actions, args?: { [key: string]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
if (!actions)
throw new Error('An active caller must be provided');
if (!this.metadata.api_url)

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import PageHeaderView from './PageHeaderView.js';
export default class PageHeader extends YTNode {
static type = 'PageHeader';
page_title: string;
content: PageHeaderView | null;
constructor(data: RawNode) {
super();
this.page_title = data.pageTitle;
this.content = Parser.parseItem(data.content, PageHeaderView);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ContentPreviewImageView from './ContentPreviewImageView.js';
import DynamicTextView from './DynamicTextView.js';
export default class PageHeaderView extends YTNode {
static type = 'PageHeaderView';
image: ContentPreviewImageView | null;
title: DynamicTextView | null;
constructor(data: RawNode) {
super();
this.image = Parser.parseItem(data.image, ContentPreviewImageView);
this.title = Parser.parseItem(data.title, DynamicTextView);
}
}

View File

@@ -17,10 +17,10 @@ export default class PlayerCaptionsTracklist extends YTNode {
audio_tracks?: {
audio_track_id: string;
captions_initial_state: string;
default_caption_track_index: number;
default_caption_track_index?: number;
has_default_track: boolean;
visibility: string;
caption_track_indices: number;
caption_track_indices: number[];
}[];
default_audio_track_index?: number;

View File

@@ -1,19 +1,21 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface StoryboardData {
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
thumbnail_count: number;
interval: number;
columns: number;
rows: number;
storyboard_count: number;
}
export default class PlayerStoryboardSpec extends YTNode {
static type = 'PlayerStoryboardSpec';
boards: {
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
thumbnail_count: number;
interval: number;
columns: number;
rows: number;
storyboard_count: number;
};
boards: StoryboardData[];
constructor(data: RawNode) {
super();

View File

@@ -8,9 +8,10 @@ export default class PlaylistHeader extends YTNode {
id: string;
title: Text;
subtitle: Text | null;
stats: Text[];
brief_stats: Text[];
author: Author;
author: Author | null;
description: Text;
num_videos: Text;
view_count: Text;
@@ -27,9 +28,10 @@ export default class PlaylistHeader extends YTNode {
super();
this.id = data.playlistId;
this.title = new Text(data.title);
this.subtitle = data.subtitle ? new Text(data.subtitle) : null;
this.stats = data.stats.map((stat: RawNode) => new Text(stat));
this.brief_stats = data.briefStats.map((stat: RawNode) => new Text(stat));
this.author = new Author({ ...data.ownerText, navigationEndpoint: data.ownerEndpoint }, data.ownerBadges, null);
this.author = data.ownerText || data.ownerEndpoint ? new Author({ ...data.ownerText, navigationEndpoint: data.ownerEndpoint }, data.ownerBadges, null) : null;
this.description = new Text(data.descriptionText);
this.num_videos = new Text(data.numVideosText);
this.view_count = new Text(data.viewCountText);

View File

@@ -0,0 +1,18 @@
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import SearchFilterGroup from './SearchFilterGroup.js';
import Text from './misc/Text.js';
export default class SearchFilterOptionsDialog extends YTNode {
static type = 'SearchFilterOptionsDialog';
title: Text;
groups: ObservedArray<SearchFilterGroup>;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import ChipCloud from './ChipCloud.js';
export default class SearchHeader extends YTNode {
static type = 'SearchHeader';
chip_bar: ChipCloud | null;
search_filter_button: Button | null;
constructor(data: RawNode) {
super();
this.chip_bar = Parser.parseItem(data.chipBar, ChipCloud);
this.search_filter_button = Parser.parseItem(data.searchFilterButton, Button);
}
}

View File

@@ -8,14 +8,19 @@ import ToggleButton from './ToggleButton.js';
export default class SearchSubMenu extends YTNode {
static type = 'SearchSubMenu';
title: Text;
groups: ObservedArray<SearchFilterGroup>;
button: ToggleButton | null;
title?: Text;
groups?: ObservedArray<SearchFilterGroup>;
button?: ToggleButton | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
this.button = Parser.parseItem(data.button, ToggleButton);
if (Reflect.has(data, 'title'))
this.title = new Text(data.title);
if (Reflect.has(data, 'groups'))
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
if (Reflect.has(data, 'button'))
this.button = Parser.parseItem(data.button, ToggleButton);
}
}

View File

@@ -1,6 +1,6 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Parser from '../parser.js';
import * as Parser from '../parser.js';
import BackstagePost from './BackstagePost.js';
import Button from './Button.js';
import Menu from './menus/Menu.js';

View File

@@ -1,6 +1,7 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ExpandableVideoDescriptionBody from './ExpandableVideoDescriptionBody.js';
import HorizontalCardList from './HorizontalCardList.js';
import VideoDescriptionHeader from './VideoDescriptionHeader.js';
import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.js';
import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
@@ -8,10 +9,10 @@ import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
export default class StructuredDescriptionContent extends YTNode {
static type = 'StructuredDescriptionContent';
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection>;
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection | HorizontalCardList>;
constructor(data: RawNode) {
super();
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection ]);
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection, HorizontalCardList ]);
}
}

View File

@@ -24,7 +24,7 @@ export default class VideoSecondaryInfo extends YTNode {
this.description = new Text(data.description);
if (Reflect.has(data, 'attributedDescription')) {
this.description = new Text(this.#convertAttributedDescriptionToRuns(data.attributedDescription));
this.description = Text.fromAttributed(data.attributedDescription);
}
this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
@@ -34,72 +34,4 @@ export default class VideoSecondaryInfo extends YTNode {
this.default_expanded = data.defaultExpanded;
this.description_collapsed_lines = data.descriptionCollapsedLines;
}
#convertAttributedDescriptionToRuns(description: RawNode) {
const runs: {
text: string,
navigationEndpoint?: RawNode,
attachment?: RawNode
}[] = [];
const content = description.content;
const command_runs = description.commandRuns;
let last_end_index = 0;
if (command_runs) {
for (const item of command_runs) {
const length: number = item.length;
const start_index: number = item.startIndex;
if (start_index > last_end_index) {
runs.push({
text: content.slice(last_end_index, start_index)
});
}
if (Reflect.has(item, 'onTap')) {
let attachment = null;
if (Reflect.has(description, 'attachmentRuns')) {
const attachment_runs = description.attachmentRuns;
for (const attatchment_run of attachment_runs) {
if ((attatchment_run.startIndex - 2) == start_index) {
attachment = attatchment_run;
break;
}
}
}
if (attachment) {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap,
attachment
});
} else {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap
});
}
}
last_end_index = start_index + length;
}
if (last_end_index < content.length) {
runs.push({
text: content.slice(last_end_index)
});
}
} else {
runs.push({
text: content
});
}
return { runs };
}
}

View File

@@ -1,16 +1,17 @@
import Parser from '../../index.js';
import type { RawNode } from '../../index.js';
import { type SuperParsedResult, YTNode } from '../../helpers.js';
import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
export default class AppendContinuationItemsAction extends YTNode {
static type = 'AppendContinuationItemsAction';
items: SuperParsedResult<YTNode>;
contents: ObservedArray<YTNode> | null;
target: string;
constructor(data: RawNode) {
super();
this.items = Parser.parse(data.continuationItems);
this.contents = Parser.parseArray(data.continuationItems);
this.target = data.target;
}
}

View File

@@ -11,7 +11,7 @@ import CommentReplyDialog from './CommentReplyDialog.js';
import PdgCommentChip from './PdgCommentChip.js';
import SponsorCommentBadge from './SponsorCommentBadge.js';
import Proto from '../../../proto/index.js';
import * as Proto from '../../../proto/index.js';
import { InnertubeError } from '../../../utils/Utils.js';
import { YTNode } from '../../helpers.js';

View File

@@ -1,9 +1,10 @@
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import CommentsSimplebox from './CommentsSimplebox.js';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.js';
export default class CommentsEntryPointHeader extends YTNode {
static type = 'CommentsEntryPointHeader';
@@ -12,7 +13,7 @@ export default class CommentsEntryPointHeader extends YTNode {
comment_count?: Text;
teaser_avatar?: Thumbnail[];
teaser_content?: Text;
content_renderer?: CommentsEntryPointTeaser | null;
content_renderer?: CommentsEntryPointTeaser | CommentsSimplebox | null;
simplebox_placeholder?: Text;
constructor(data: RawNode) {
@@ -35,7 +36,7 @@ export default class CommentsEntryPointHeader extends YTNode {
}
if (Reflect.has(data, 'contentRenderer')) {
this.content_renderer = Parser.parseItem(data.contentRenderer, CommentsEntryPointTeaser);
this.content_renderer = Parser.parseItem(data.contentRenderer, [ CommentsEntryPointTeaser, CommentsSimplebox ]);
}
if (Reflect.has(data, 'simpleboxPlaceholder')) {

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../../helpers.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import type { RawNode } from '../../index.js';
export default class CommentsSimplebox extends YTNode {
static type = 'CommentsSimplebox';
simplebox_avatar: Thumbnail[];
simplebox_placeholder: Text;
constructor(data: RawNode) {
super();
this.simplebox_avatar = Thumbnail.fromResponse(data.simpleboxAvatar);
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
}
}

View File

@@ -104,7 +104,7 @@ export default class Format {
this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;
this.is_dubbed = audio_content === 'dubbed';
this.is_descriptive = audio_content === 'descriptive';
this.is_original = audio_content === 'original' || !this.is_dubbed || !this.is_descriptive;
this.is_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive);
if (Reflect.has(data, 'audioTrack')) {
this.audio_track = {

View File

@@ -46,6 +46,74 @@ export default class Text {
}
}
static fromAttributed(data: RawNode): Text {
const runs: {
text: string,
navigationEndpoint?: RawNode,
attachment?: RawNode
}[] = [];
const content = data.content;
const command_runs = data.commandRuns;
let last_end_index = 0;
if (command_runs) {
for (const item of command_runs) {
const length: number = item.length;
const start_index: number = item.startIndex;
if (start_index > last_end_index) {
runs.push({
text: content.slice(last_end_index, start_index)
});
}
if (Reflect.has(item, 'onTap')) {
let attachment = null;
if (Reflect.has(data, 'attachmentRuns')) {
const attachment_runs = data.attachmentRuns;
for (const attatchment_run of attachment_runs) {
if ((attatchment_run.startIndex - 2) == start_index) {
attachment = attatchment_run;
break;
}
}
}
if (attachment) {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap,
attachment
});
} else {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap
});
}
}
last_end_index = start_index + length;
}
if (last_end_index < content.length) {
runs.push({
text: content.slice(last_end_index)
});
}
} else {
runs.push({
text: content
});
}
return new Text({ runs });
}
/**
* Converts the text to HTML.
* @returns The HTML.

211
src/parser/continuations.ts Normal file
View File

@@ -0,0 +1,211 @@
import type { ObservedArray} from './helpers.js';
import { YTNode, observe } from './helpers.js';
import type { RawNode } from './index.js';
import { Thumbnail } from './misc.js';
import { NavigationEndpoint, LiveChatItemList, LiveChatHeader, LiveChatParticipantsList, Message } from './nodes.js';
import * as Parser from './parser.js';
export class ItemSectionContinuation extends YTNode {
static readonly type = 'itemSectionContinuation';
contents: ObservedArray<YTNode> | null;
continuation?: string;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
if (Array.isArray(data.continuations)) {
this.continuation = data.continuations?.at(0)?.nextContinuationData?.continuation;
}
}
}
export class NavigateAction extends YTNode {
static readonly type = 'navigateAction';
endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.endpoint = new NavigationEndpoint(data.endpoint);
}
}
export class ShowMiniplayerCommand extends YTNode {
static readonly type = 'showMiniplayerCommand';
miniplayer_command: NavigationEndpoint;
show_premium_branding: boolean;
constructor(data: RawNode) {
super();
this.miniplayer_command = new NavigationEndpoint(data.miniplayerCommand);
this.show_premium_branding = data.showPremiumBranding;
}
}
export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js';
export class ReloadContinuationItemsCommand extends YTNode {
static readonly type = 'reloadContinuationItemsCommand';
target_id: string;
contents: ObservedArray<YTNode> | null;
slot?: string;
constructor(data: RawNode) {
super();
this.target_id = data.targetId;
this.contents = Parser.parse(data.continuationItems, true);
this.slot = data?.slot;
}
}
export class SectionListContinuation extends YTNode {
static readonly type = 'sectionListContinuation';
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation =
data.continuations?.[0]?.nextContinuationData?.continuation ||
data.continuations?.[0]?.reloadContinuationData?.continuation || null;
}
}
export class MusicPlaylistShelfContinuation extends YTNode {
static readonly type = 'musicPlaylistShelfContinuation';
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
}
}
export class MusicShelfContinuation extends YTNode {
static readonly type = 'musicShelfContinuation';
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
this.continuation =
data.continuations?.[0].nextContinuationData?.continuation ||
data.continuations?.[0].reloadContinuationData?.continuation || null;
}
}
export class GridContinuation extends YTNode {
static readonly type = 'gridContinuation';
continuation: string;
items: ObservedArray<YTNode> | null;
constructor(data: RawNode) {
super();
this.items = Parser.parse(data.items, true);
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
}
get contents() {
return this.items;
}
}
export class PlaylistPanelContinuation extends YTNode {
static readonly type = 'playlistPanelContinuation';
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation ||
data.continuations?.[0]?.nextRadioContinuationData?.continuation || null;
}
}
export class Continuation extends YTNode {
static readonly type = 'continuation';
continuation_type: string;
timeout_ms?: number;
time_until_last_message_ms?: number;
token: string;
constructor(data: RawNode) {
super();
this.continuation_type = data.type;
this.timeout_ms = data.continuation?.timeoutMs;
this.time_until_last_message_ms = data.continuation?.timeUntilLastMessageMsec;
this.token = data.continuation?.continuation;
}
}
export class LiveChatContinuation extends YTNode {
static readonly type = 'liveChatContinuation';
actions: ObservedArray<YTNode>;
action_panel: YTNode | null;
item_list: LiveChatItemList | null;
header: LiveChatHeader | null;
participants_list: LiveChatParticipantsList | null;
popout_message: Message | null;
emojis: {
emoji_id: string;
shortcuts: string[];
search_terms: string[];
image: Thumbnail[];
}[];
continuation: Continuation;
viewer_name: string;
constructor(data: RawNode) {
super();
this.actions = Parser.parse(data.actions?.map((action: any) => {
delete action.clickTrackingParams;
return action;
}), true) || observe<YTNode>([]);
this.action_panel = Parser.parseItem(data.actionPanel);
this.item_list = Parser.parseItem(data.itemList, LiveChatItemList);
this.header = Parser.parseItem(data.header, LiveChatHeader);
this.participants_list = Parser.parseItem(data.participantsList, LiveChatParticipantsList);
this.popout_message = Parser.parseItem(data.popoutMessage, Message);
this.emojis = data.emojis?.map((emoji: any) => ({
emoji_id: emoji.emojiId,
shortcuts: emoji.shortcuts,
search_terms: emoji.searchTerms,
image: Thumbnail.fromResponse(emoji.image),
is_custom_emoji: emoji.isCustomEmoji
})) || [];
let continuation, type;
if (data.continuations?.[0].timedContinuationData) {
type = 'timed';
continuation = data.continuations?.[0].timedContinuationData;
} else if (data.continuations?.[0].invalidationContinuationData) {
type = 'invalidation';
continuation = data.continuations?.[0].invalidationContinuationData;
} else if (data.continuations?.[0].liveChatReplayContinuationData) {
type = 'replay';
continuation = data.continuations?.[0].liveChatReplayContinuationData;
}
this.continuation = new Continuation({ continuation, type });
this.viewer_name = data.viewerName;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
export { default as Parser } from './parser.js';
export * from './parser.js';
export * as Parser from './parser.js';
export * from './continuations.js';
export * from './types/index.js';
export * as Misc from './misc.js';
export * as YTNodes from './nodes.js';
@@ -8,5 +8,5 @@ export * as YTMusic from './ytmusic/index.js';
export * as YTKids from './ytkids/index.js';
export * as Helpers from './helpers.js';
export * as Generator from './generator.js';
import Parser from './parser.js';
import * as Parser from './parser.js';
export default Parser;

View File

@@ -8,6 +8,7 @@ export { default as AccountSectionList } from './classes/AccountSectionList.js';
export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js';
export { default as OpenPopupAction } from './classes/actions/OpenPopupAction.js';
export { default as Alert } from './classes/Alert.js';
export { default as AlertWithButton } from './classes/AlertWithButton.js';
export { default as AnalyticsMainAppKeyMetrics } from './classes/analytics/AnalyticsMainAppKeyMetrics.js';
export { default as AnalyticsRoot } from './classes/analytics/AnalyticsRoot.js';
export { default as AnalyticsShortsCarouselCard } from './classes/analytics/AnalyticsShortsCarouselCard.js';
@@ -36,6 +37,7 @@ export { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullM
export { default as ChannelAgeGate } from './classes/ChannelAgeGate.js';
export { default as ChannelFeaturedContent } from './classes/ChannelFeaturedContent.js';
export { default as ChannelHeaderLinks } from './classes/ChannelHeaderLinks.js';
export { default as ChannelHeaderLinksView } from './classes/ChannelHeaderLinksView.js';
export { default as ChannelMetadata } from './classes/ChannelMetadata.js';
export { default as ChannelMobileHeader } from './classes/ChannelMobileHeader.js';
export { default as ChannelOptions } from './classes/ChannelOptions.js';
@@ -58,6 +60,7 @@ export { default as CommentsEntryPointHeader } from './classes/comments/Comments
export { default as CommentsEntryPointTeaser } from './classes/comments/CommentsEntryPointTeaser.js';
export { default as CommentsHeader } from './classes/comments/CommentsHeader.js';
export { default as CommentSimplebox } from './classes/comments/CommentSimplebox.js';
export { default as CommentsSimplebox } from './classes/comments/CommentsSimplebox.js';
export { default as CommentThread } from './classes/comments/CommentThread.js';
export { default as CreatorHeart } from './classes/comments/CreatorHeart.js';
export { default as EmojiPicker } from './classes/comments/EmojiPicker.js';
@@ -66,10 +69,12 @@ export { default as SponsorCommentBadge } from './classes/comments/SponsorCommen
export { default as CompactChannel } from './classes/CompactChannel.js';
export { default as CompactLink } from './classes/CompactLink.js';
export { default as CompactMix } from './classes/CompactMix.js';
export { default as CompactMovie } from './classes/CompactMovie.js';
export { default as CompactPlaylist } from './classes/CompactPlaylist.js';
export { default as CompactStation } from './classes/CompactStation.js';
export { default as CompactVideo } from './classes/CompactVideo.js';
export { default as ConfirmDialog } from './classes/ConfirmDialog.js';
export { default as ContentPreviewImageView } from './classes/ContentPreviewImageView.js';
export { default as ContinuationItem } from './classes/ContinuationItem.js';
export { default as ConversationBar } from './classes/ConversationBar.js';
export { default as CopyLink } from './classes/CopyLink.js';
@@ -80,6 +85,7 @@ export { default as DidYouMean } from './classes/DidYouMean.js';
export { default as DownloadButton } from './classes/DownloadButton.js';
export { default as Dropdown } from './classes/Dropdown.js';
export { default as DropdownItem } from './classes/DropdownItem.js';
export { default as DynamicTextView } from './classes/DynamicTextView.js';
export { default as Element } from './classes/Element.js';
export { default as EmergencyOnebox } from './classes/EmergencyOnebox.js';
export { default as EmojiPickerCategory } from './classes/EmojiPickerCategory.js';
@@ -115,6 +121,7 @@ export { default as GuideEntry } from './classes/GuideEntry.js';
export { default as GuideSection } from './classes/GuideSection.js';
export { default as GuideSubscriptionsSection } from './classes/GuideSubscriptionsSection.js';
export { default as HashtagHeader } from './classes/HashtagHeader.js';
export { default as HashtagTile } from './classes/HashtagTile.js';
export { default as Heatmap } from './classes/Heatmap.js';
export { default as HeatMarker } from './classes/HeatMarker.js';
export { default as HeroPlaylistThumbnail } from './classes/HeroPlaylistThumbnail.js';
@@ -124,6 +131,7 @@ export { default as HorizontalCardList } from './classes/HorizontalCardList.js';
export { default as HorizontalList } from './classes/HorizontalList.js';
export { default as HorizontalMovieList } from './classes/HorizontalMovieList.js';
export { default as IconLink } from './classes/IconLink.js';
export { default as IncludingResultsFor } from './classes/IncludingResultsFor.js';
export { default as InfoPanelContainer } from './classes/InfoPanelContainer.js';
export { default as InfoPanelContent } from './classes/InfoPanelContent.js';
export { default as InfoRow } from './classes/InfoRow.js';
@@ -178,6 +186,8 @@ export { default as LiveChatItemList } from './classes/LiveChatItemList.js';
export { default as LiveChatMessageInput } from './classes/LiveChatMessageInput.js';
export { default as LiveChatParticipant } from './classes/LiveChatParticipant.js';
export { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList.js';
export { default as MacroMarkersInfoItem } from './classes/MacroMarkersInfoItem.js';
export { default as MacroMarkersList } from './classes/MacroMarkersList.js';
export { default as MacroMarkersListItem } from './classes/MacroMarkersListItem.js';
export { default as Menu } from './classes/menus/Menu.js';
export { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem.js';
@@ -216,6 +226,7 @@ export { default as MusicImmersiveHeader } from './classes/MusicImmersiveHeader.
export { default as MusicInlineBadge } from './classes/MusicInlineBadge.js';
export { default as MusicItemThumbnailOverlay } from './classes/MusicItemThumbnailOverlay.js';
export { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardItemCarousel.js';
export { default as MusicMultiRowListItem } from './classes/MusicMultiRowListItem.js';
export { default as MusicNavigationButton } from './classes/MusicNavigationButton.js';
export { default as MusicPlayButton } from './classes/MusicPlayButton.js';
export { default as MusicPlaylistShelf } from './classes/MusicPlaylistShelf.js';
@@ -233,6 +244,8 @@ export { default as MusicTwoRowItem } from './classes/MusicTwoRowItem.js';
export { default as MusicVisualHeader } from './classes/MusicVisualHeader.js';
export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js';
export { default as Notification } from './classes/Notification.js';
export { default as PageHeader } from './classes/PageHeader.js';
export { default as PageHeaderView } from './classes/PageHeaderView.js';
export { default as PageIntroduction } from './classes/PageIntroduction.js';
export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.js';
export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.js';
@@ -280,6 +293,8 @@ export { default as RichShelf } from './classes/RichShelf.js';
export { default as SearchBox } from './classes/SearchBox.js';
export { default as SearchFilter } from './classes/SearchFilter.js';
export { default as SearchFilterGroup } from './classes/SearchFilterGroup.js';
export { default as SearchFilterOptionsDialog } from './classes/SearchFilterOptionsDialog.js';
export { default as SearchHeader } from './classes/SearchHeader.js';
export { default as SearchRefinementCard } from './classes/SearchRefinementCard.js';
export { default as SearchSubMenu } from './classes/SearchSubMenu.js';
export { default as SearchSuggestion } from './classes/SearchSuggestion.js';

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers.js';
import type {
ReloadContinuationItemsCommand, AppendContinuationItemsAction, Continuation, GridContinuation,
ReloadContinuationItemsCommand, Continuation, GridContinuation,
ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation,
PlaylistPanelContinuation, SectionListContinuation
} from '../index.js';
@@ -15,9 +15,11 @@ import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.j
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js';
import type VideoDetails from '../classes/misc/VideoDetails.js';
import type Alert from '../classes/Alert.js';
import type AlertWithButton from '../classes/AlertWithButton.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js';
import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.js';
import type { AppendContinuationItemsAction } from '../nodes.js';
export interface IParsedResponse {
actions?: SuperParsedResult<YTNode>;
actions_memo?: Memo;
@@ -43,7 +45,7 @@ export interface IParsedResponse {
metadata?: SuperParsedResult<YTNode>;
microformat?: YTNode;
overlay?: YTNode;
alerts?: ObservedArray<Alert>;
alerts?: ObservedArray<Alert | AlertWithButton>;
refinements?: string[];
estimated_results?: number;
player_overlays?: SuperParsedResult<YTNode>;
@@ -51,20 +53,8 @@ export interface IParsedResponse {
videostats_watchtime_url: string;
videostats_playback_url: string;
};
playability_status?: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: string;
};
streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
};
playability_status?: IPlayabilityStatus;
streaming_data?: IStreamingData;
current_video_endpoint?: NavigationEndpoint;
endpoint?: NavigationEndpoint;
captions?: PlayerCaptionsTracklist;
@@ -77,26 +67,22 @@ export interface IParsedResponse {
items?: SuperParsedResult<YTNode>;
}
export interface IStreamingData {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}
export interface IPlayerResponse {
captions?: PlayerCaptionsTracklist;
cards?: CardCollection;
endscreen?: Endscreen;
microformat?: YTNode;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
playability_status: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: string;
};
streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
};
playability_status: IPlayabilityStatus;
streaming_data?: IStreamingData;
playback_tracking?: {
videostats_watchtime_url: string;
videostats_playback_url: string;
@@ -105,6 +91,14 @@ export interface IPlayerResponse {
video_details?: VideoDetails;
}
export interface IPlayabilityStatus {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: string;
}
export interface INextResponse {
contents?: SuperParsedResult<YTNode>;
contents_memo?: Memo;
@@ -129,7 +123,7 @@ export interface IBrowseResponse {
header_memo?: Memo;
metadata?: SuperParsedResult<YTNode>;
microformat?: YTNode;
alerts?: ObservedArray<Alert>;
alerts?: ObservedArray<Alert | AlertWithButton>;
sidebar?: YTNode;
sidebar_memo?: Memo;
}

View File

@@ -54,4 +54,5 @@ export interface IRawResponse {
items?: RawNode[];
frameworkUpdates?: any;
engagementPanels: RawNode[];
[key: string]: any;
}

View File

@@ -9,6 +9,8 @@ import SubscribeButton from '../classes/SubscribeButton.js';
import ExpandableTab from '../classes/ExpandableTab.js';
import SectionList from '../classes/SectionList.js';
import Tab from '../classes/Tab.js';
import PageHeader from '../classes/PageHeader.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import Feed from '../../core/mixins/Feed.js';
import FilterableFeed from '../../core/mixins/FilterableFeed.js';
@@ -25,7 +27,7 @@ import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/index.js';
export default class Channel extends TabbedFeed<IBrowseResponse> {
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader;
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader;
metadata;
subscribe_button?: SubscribeButton;
current_tab?: Tab | ExpandableTab;
@@ -33,7 +35,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader);
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader, PageHeader);
const metadata = this.page.metadata?.item().as(ChannelMetadata);
const microformat = this.page.microformat?.as(MicroformatData);
@@ -52,7 +54,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
this.subscribe_button = this.page.header_memo?.getType(SubscribeButton).first();
this.current_tab = this.page.contents?.item().key('tabs').parsed().array().filterType(Tab, ExpandableTab).get({ selected: true });
this.current_tab = this.page.contents?.item().as(TwoColumnBrowseResults).tabs.array().filterType(Tab, ExpandableTab).get({ selected: true });
}
/**

View File

@@ -8,9 +8,10 @@ import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type ChipCloudChip from '../classes/ChipCloudChip.js';
import type { IBrowseResponse } from '../index.js';
import { PageHeader } from '../nodes.js';
export default class HashtagFeed extends FilterableFeed<IBrowseResponse> {
header?: HashtagHeader;
header?: HashtagHeader | PageHeader;
contents: RichGrid;
constructor(actions: Actions, response: IBrowseResponse | ApiResponse) {
@@ -25,7 +26,7 @@ export default class HashtagFeed extends FilterableFeed<IBrowseResponse> {
throw new InnertubeError('Content tab has no content', tab);
if (this.page.header) {
this.header = this.page.header.item().as(HashtagHeader);
this.header = this.page.header.item().as(HashtagHeader, PageHeader);
}
this.contents = tab.content.as(RichGrid);

View File

@@ -21,7 +21,7 @@ import type AddBannerToLiveChatCommand from '../classes/livechat/AddBannerToLive
import type RemoveBannerForLiveChatCommand from '../classes/livechat/RemoveBannerForLiveChatCommand.js';
import type ShowLiveChatTooltipCommand from '../classes/livechat/ShowLiveChatTooltipCommand.js';
import Proto from '../../proto/index.js';
import * as Proto from '../../proto/index.js';
import { InnertubeError, Platform } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';

View File

@@ -36,6 +36,7 @@ class Playlist extends Feed<IBrowseResponse> {
this.info = {
...this.page.metadata?.item().as(PlaylistMetadata),
...{
subtitle: header ? header.subtitle : null,
author: secondary_info?.owner?.as(VideoOwner).author ?? header?.author,
thumbnails: primary_info?.thumbnail_renderer?.as(PlaylistVideoThumbnail, PlaylistCustomThumbnail).thumbnail as Thumbnail[],
total_items: this.#getStat(0, primary_info),

View File

@@ -2,6 +2,7 @@ import Feed from '../../core/mixins/Feed.js';
import { InnertubeError } from '../../utils/Utils.js';
import HorizontalCardList from '../classes/HorizontalCardList.js';
import ItemSection from '../classes/ItemSection.js';
import SearchHeader from '../classes/SearchHeader.js';
import SearchRefinementCard from '../classes/SearchRefinementCard.js';
import SearchSubMenu from '../classes/SearchSubMenu.js';
import SectionList from '../classes/SectionList.js';
@@ -11,8 +12,8 @@ import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/ParsedResponse.js';
class Search extends Feed<ISearchResponse> {
header?: SearchHeader;
results?: ObservedArray<YTNode> | null;
refinements: string[];
estimated_results: number;
@@ -30,6 +31,9 @@ class Search extends Feed<ISearchResponse> {
if (!contents)
throw new InnertubeError('No contents found in search response');
if (this.page.header)
this.header = this.page.header.item().as(SearchHeader);
this.results = contents.find((content) => content.is(ItemSection) && content.contents && content.contents.length > 0)?.as(ItemSection).contents;
this.refinements = this.page.refinements || [];

View File

@@ -99,6 +99,27 @@ class VideoInfo extends MediaInfo {
this.captions = info.captions;
this.cards = info.cards;
if (this.streaming_data) {
const default_audio_track = this.streaming_data.adaptive_formats.find((format) => format.audio_track?.audio_is_default);
if (default_audio_track) {
// The combined formats only exist for the default language, even for videos with multiple audio tracks
// So we can copy the language from the default audio track to the combined formats
this.streaming_data.formats.forEach((format) => format.language = default_audio_track.language);
} else if (typeof this.captions?.default_audio_track_index !== 'undefined' && this.captions?.audio_tracks && this.captions.caption_tracks) {
// For videos with a single audio track and captions, we can use the captions to figure out the language of the audio and combined formats
const audioTrack = this.captions.audio_tracks[this.captions.default_audio_track_index];
const index = audioTrack.default_caption_track_index || 0;
const language_code = this.captions.caption_tracks[index].language_code;
this.streaming_data.adaptive_formats.forEach((format) => {
if (format.has_audio) {
format.language = language_code;
}
});
this.streaming_data.formats.forEach((format) => format.language = language_code);
}
}
const two_col = next?.contents?.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;

View File

@@ -29,7 +29,7 @@ class HomeFeed {
if (!tab)
throw new InnertubeError('Could not find Home tab.');
if (tab.key('content').isNull()) {
if (tab.content === null) {
if (!this.#page.continuation_contents)
throw new InnertubeError('Continuation did not have any content.');

View File

@@ -164,8 +164,7 @@ class LibraryContinuation {
this.contents = this.#page.continuation_contents.as(MusicShelfContinuation, GridContinuation);
this.#continuation = this.#page.continuation_contents?.key('continuation').isNull()
? null : this.#page.continuation_contents?.key('continuation').string();
this.#continuation = this.contents.continuation || null;
}
async getContinuation(): Promise<LibraryContinuation> {

View File

@@ -86,7 +86,7 @@ class TrackInfo extends MediaInfo {
const page = await target_tab.endpoint.call(this.actions, { client: 'YTMUSIC', parse: true });
if (page.contents?.item().key('type').string() === 'Message')
if (page.contents?.item().type === 'Message')
return page.contents.item().as(Message);
if (!page.contents)

View File

@@ -24,7 +24,6 @@ If you wish to bring YouTube.js to another platform, you will need to provide th
- `sha1hash(data: string)`: Function that takes a string and returns a SHA-1 hash of it.
- `uuidv4()`: Function that returns a UUIDv4 string.
- `eval(code: string, env: Record<string, VMPrimative>)`: Function to evaluate untrusted javascript script and return the result.
- `DOMParser`: DOMParser implementation. Used for generating DASH manifests.
- `fetch`: WHATWG Fetch API implementation.
- `Headers`: Headers implementation.
- `Request`: Request implementation.

View File

@@ -1,7 +1,6 @@
// Deno Platform Support
import type { ICache } from '../types/Cache.js';
import { Platform } from '../utils/Utils.js';
import DOMParser from './polyfills/server-dom.js';
import evaluate from './jsruntime/jinter.js';
import sha1Hash from './polyfills/web-crypto.js';
import package_json from '../../package.json' assert { type: 'json' };
@@ -95,10 +94,6 @@ Platform.load({
return crypto.randomUUID();
},
eval: evaluate,
DOMParser,
serializeDOM(document) {
return document.toString();
},
fetch: globalThis.fetch,
Request: globalThis.Request,
Response: globalThis.Response,

View File

@@ -3,7 +3,7 @@ import Innertube from '../Innertube.js';
export * from '../core/index.js';
export * from '../parser/index.js';
export { default as Parser } from '../parser/index.js';
export { default as Proto } from '../proto/index.js';
export * as Proto from '../proto/index.js';
export * as Types from '../types/index.js';
export * from '../utils/index.js';

View File

@@ -16,7 +16,6 @@ import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { readFileSync } from 'fs';
import DOMParser from './polyfills/server-dom.js';
import CustomEvent from './polyfills/node-custom-event.js';
import { fileURLToPath } from 'url';
import evaluate from './jsruntime/jinter.js';
@@ -114,11 +113,7 @@ Platform.load({
uuidv4() {
return crypto.randomUUID();
},
serializeDOM(document) {
return document.toString();
},
eval: evaluate,
DOMParser,
fetch: defaultFetch as unknown as FetchFunction,
Request: Request as unknown as typeof globalThis.Request,
Response: Response as unknown as typeof globalThis.Response,

View File

@@ -1,3 +0,0 @@
import { DOMParser as DOMParserImpl } from 'linkedom';
export default DOMParserImpl as typeof globalThis.DOMParser;

View File

@@ -105,10 +105,6 @@ Platform.load({
});
},
eval: evaluate,
DOMParser: globalThis.DOMParser,
serializeDOM(document) {
return new XMLSerializer().serializeToString(document);
},
fetch: globalThis.fetch,
Request: globalThis.Request,
Response: globalThis.Response,

View File

@@ -15,321 +15,317 @@ import * as NotificationPreferences from './generated/messages/youtube/Notificat
import * as InnertubePayload from './generated/messages/youtube/InnertubePayload.js';
import * as Hashtag from './generated/messages/youtube/Hashtag.js';
class Proto {
static encodeVisitorData(id: string, timestamp: number): string {
const buf = VisitorData.encodeBinary({ id, timestamp });
return encodeURIComponent(u8ToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_'));
}
static decodeVisitorData(visitor_data: string): VisitorData.Type {
const data = VisitorData.decodeBinary(base64ToU8(decodeURIComponent(visitor_data)));
return data;
}
static encodeChannelAnalyticsParams(channel_id: string): string {
const buf = ChannelAnalytics.encodeBinary({
params: {
channelId: channel_id
}
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeSearchFilters(filters: {
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')[]
}): string {
const upload_date = {
all: undefined,
hour: 1,
today: 2,
week: 3,
month: 4,
year: 5
};
const type = {
all: undefined,
video: 1,
channel: 2,
playlist: 3,
movie: 4
};
const duration = {
all: undefined,
short: 1,
long: 2,
medium: 3
};
const order = {
relevance: undefined,
rating: 1,
upload_date: 2,
view_count: 3
};
const features = {
hd: 'featuresHd',
subtitles: 'featuresSubtitles',
creative_commons: 'featuresCreativeCommons',
'3d': 'features3D',
live: 'featuresLive',
purchased: 'featuresPurchased',
'4k': 'features4K',
'360': 'features360',
location: 'featuresLocation',
hdr: 'featuresHdr',
vr180: 'featuresVr180'
};
const data: SearchFilter.Type = {};
if (filters)
data.filters = {};
else
data.noFilter = 0;
if (data.filters) {
if (filters.upload_date) {
data.filters.uploadDate = upload_date[filters.upload_date];
}
if (filters.type) {
data.filters.type = type[filters.type];
}
if (filters.duration) {
data.filters.duration = duration[filters.duration];
}
if (filters.sort_by && filters.sort_by !== 'relevance') {
data.sortBy = order[filters.sort_by];
}
if (filters.features) {
for (const feature of filters.features) {
data.filters[features[feature] as keyof SearchFilter_Filters.Type] = 1;
}
}
}
const buf = SearchFilter.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
static encodeMusicSearchFilters(filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'
}): string {
const data: MusicSearchFilter.Type = {
filters: {
type: {}
}
};
// TODO: See protobuf definition (protoc doesn't allow zero index: optional int32 all = 0;)
if (filters.type && filters.type !== 'all' && data.filters?.type)
data.filters.type[filters.type] = 1;
const buf = MusicSearchFilter.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
static encodeMessageParams(channel_id: string, video_id: string): string {
const buf = LiveMessageParams.encodeBinary({
params: {
ids: {
channelId: channel_id, videoId: video_id
}
},
number0: 1, number1: 4
});
return btoa(encodeURIComponent(u8ToBase64(buf)));
}
static encodeCommentsSectionParams(video_id: string, options: {
type?: number,
sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'
} = {}): string {
const sort_options = {
TOP_COMMENTS: 0,
NEWEST_FIRST: 1
};
const buf = GetCommentsSectionParams.encodeBinary({
ctx: {
videoId: video_id
},
unkParam: 6,
params: {
opts: {
videoId: video_id,
sortBy: sort_options[options.sort_by || 'TOP_COMMENTS'],
type: options.type || 2
},
target: 'comments-section'
}
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentParams(video_id: string): string {
const buf = CreateCommentParams.encodeBinary({
videoId: video_id,
params: {
index: 0
},
number: 7
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentActionParams(type: number, args: {
comment_id?: string,
video_id?: string,
text?: string,
target_language?: string
} = {}): string {
const data: PeformCommentActionParams.Type = {
type,
commentId: args.comment_id || ' ',
videoId: args.video_id || ' ',
channelId: ' ',
unkNum: 2
};
if (args.hasOwnProperty('text')) {
if (typeof args.target_language !== 'string')
throw new Error('target_language must be a string');
args.comment_id && (delete data.unkNum);
data.translateCommentParams = {
params: {
comment: {
text: args.text as string
}
},
commentId: args.comment_id || ' ',
targetLanguage: args.target_language
};
}
const buf = PeformCommentActionParams.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
static encodeNotificationPref(channel_id: string, index: number): string {
const buf = NotificationPreferences.encodeBinary({
channelId: channel_id,
prefId: {
index
},
number0: 0, number1: 4
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeVideoMetadataPayload(video_id: string, metadata: UpdateVideoMetadataOptions): Uint8Array {
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
}
},
target: video_id
};
if (Reflect.has(metadata, 'title'))
data.title = { text: metadata.title || '' };
if (Reflect.has(metadata, 'description'))
data.description = { text: metadata.description || '' };
if (Reflect.has(metadata, 'license'))
data.license = { type: metadata.license || '' };
if (Reflect.has(metadata, 'tags'))
data.tags = { list: metadata.tags || [] };
if (Reflect.has(metadata, 'category'))
data.category = { id: metadata.category || 0 };
if (Reflect.has(metadata, 'privacy')) {
switch (metadata.privacy) {
case 'PUBLIC':
data.privacy = { type: 1 };
break;
case 'UNLISTED':
data.privacy = { type: 2 };
break;
case 'PRIVATE':
data.privacy = { type: 3 };
break;
default:
throw new Error('Invalid visibility option');
}
}
if (Reflect.has(metadata, 'made_for_kids')) {
data.madeForKids = {
unkparam: 1,
choice: metadata.made_for_kids ? 1 : 2
};
}
if (Reflect.has(metadata, 'age_restricted')) {
data.ageRestricted = {
unkparam: 1,
choice: metadata.age_restricted ? 1 : 2
};
}
const buf = InnertubePayload.encodeBinary(data);
return buf;
}
static encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array): Uint8Array {
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
}
},
target: video_id,
videoThumbnail: {
type: 3,
thumbnail: {
imageData: bytes
}
}
};
const buf = InnertubePayload.encodeBinary(data);
return buf;
}
static encodeHashtag(hashtag: string): string {
const buf = Hashtag.encodeBinary({
params: {
hashtag,
type: 1
}
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeVisitorData(id: string, timestamp: number): string {
const buf = VisitorData.encodeBinary({ id, timestamp });
return encodeURIComponent(u8ToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_'));
}
export default Proto;
export function decodeVisitorData(visitor_data: string): VisitorData.Type {
const data = VisitorData.decodeBinary(base64ToU8(decodeURIComponent(visitor_data)));
return data;
}
export function encodeChannelAnalyticsParams(channel_id: string): string {
const buf = ChannelAnalytics.encodeBinary({
params: {
channelId: channel_id
}
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeSearchFilters(filters: {
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')[]
}): string {
const upload_date = {
all: undefined,
hour: 1,
today: 2,
week: 3,
month: 4,
year: 5
};
const type = {
all: undefined,
video: 1,
channel: 2,
playlist: 3,
movie: 4
};
const duration = {
all: undefined,
short: 1,
long: 2,
medium: 3
};
const order = {
relevance: undefined,
rating: 1,
upload_date: 2,
view_count: 3
};
const features = {
hd: 'featuresHd',
subtitles: 'featuresSubtitles',
creative_commons: 'featuresCreativeCommons',
'3d': 'features3D',
live: 'featuresLive',
purchased: 'featuresPurchased',
'4k': 'features4K',
'360': 'features360',
location: 'featuresLocation',
hdr: 'featuresHdr',
vr180: 'featuresVr180'
};
const data: SearchFilter.Type = {};
if (filters)
data.filters = {};
else
data.noFilter = 0;
if (data.filters) {
if (filters.upload_date) {
data.filters.uploadDate = upload_date[filters.upload_date];
}
if (filters.type) {
data.filters.type = type[filters.type];
}
if (filters.duration) {
data.filters.duration = duration[filters.duration];
}
if (filters.sort_by && filters.sort_by !== 'relevance') {
data.sortBy = order[filters.sort_by];
}
if (filters.features) {
for (const feature of filters.features) {
data.filters[features[feature] as keyof SearchFilter_Filters.Type] = 1;
}
}
}
const buf = SearchFilter.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeMusicSearchFilters(filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'
}): string {
const data: MusicSearchFilter.Type = {
filters: {
type: {}
}
};
// TODO: See protobuf definition (protoc doesn't allow zero index: optional int32 all = 0;)
if (filters.type && filters.type !== 'all' && data.filters?.type)
data.filters.type[filters.type] = 1;
const buf = MusicSearchFilter.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeMessageParams(channel_id: string, video_id: string): string {
const buf = LiveMessageParams.encodeBinary({
params: {
ids: {
channelId: channel_id, videoId: video_id
}
},
number0: 1, number1: 4
});
return btoa(encodeURIComponent(u8ToBase64(buf)));
}
export function encodeCommentsSectionParams(video_id: string, options: {
type?: number,
sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'
} = {}): string {
const sort_options = {
TOP_COMMENTS: 0,
NEWEST_FIRST: 1
};
const buf = GetCommentsSectionParams.encodeBinary({
ctx: {
videoId: video_id
},
unkParam: 6,
params: {
opts: {
videoId: video_id,
sortBy: sort_options[options.sort_by || 'TOP_COMMENTS'],
type: options.type || 2
},
target: 'comments-section'
}
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeCommentParams(video_id: string): string {
const buf = CreateCommentParams.encodeBinary({
videoId: video_id,
params: {
index: 0
},
number: 7
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeCommentActionParams(type: number, args: {
comment_id?: string,
video_id?: string,
text?: string,
target_language?: string
} = {}): string {
const data: PeformCommentActionParams.Type = {
type,
commentId: args.comment_id || ' ',
videoId: args.video_id || ' ',
channelId: ' ',
unkNum: 2
};
if (args.hasOwnProperty('text')) {
if (typeof args.target_language !== 'string')
throw new Error('target_language must be a string');
args.comment_id && (delete data.unkNum);
data.translateCommentParams = {
params: {
comment: {
text: args.text as string
}
},
commentId: args.comment_id || ' ',
targetLanguage: args.target_language
};
}
const buf = PeformCommentActionParams.encodeBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeNotificationPref(channel_id: string, index: number): string {
const buf = NotificationPreferences.encodeBinary({
channelId: channel_id,
prefId: {
index
},
number0: 0, number1: 4
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeVideoMetadataPayload(video_id: string, metadata: UpdateVideoMetadataOptions): Uint8Array {
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
}
},
target: video_id
};
if (Reflect.has(metadata, 'title'))
data.title = { text: metadata.title || '' };
if (Reflect.has(metadata, 'description'))
data.description = { text: metadata.description || '' };
if (Reflect.has(metadata, 'license'))
data.license = { type: metadata.license || '' };
if (Reflect.has(metadata, 'tags'))
data.tags = { list: metadata.tags || [] };
if (Reflect.has(metadata, 'category'))
data.category = { id: metadata.category || 0 };
if (Reflect.has(metadata, 'privacy')) {
switch (metadata.privacy) {
case 'PUBLIC':
data.privacy = { type: 1 };
break;
case 'UNLISTED':
data.privacy = { type: 2 };
break;
case 'PRIVATE':
data.privacy = { type: 3 };
break;
default:
throw new Error('Invalid visibility option');
}
}
if (Reflect.has(metadata, 'made_for_kids')) {
data.madeForKids = {
unkparam: 1,
choice: metadata.made_for_kids ? 1 : 2
};
}
if (Reflect.has(metadata, 'age_restricted')) {
data.ageRestricted = {
unkparam: 1,
choice: metadata.age_restricted ? 1 : 2
};
}
const buf = InnertubePayload.encodeBinary(data);
return buf;
}
export function encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array): Uint8Array {
const data: InnertubePayload.Type = {
context: {
client: {
unkparam: 14,
clientName: CLIENTS.ANDROID.NAME,
clientVersion: CLIENTS.YTSTUDIO_ANDROID.VERSION
}
},
target: video_id,
videoThumbnail: {
type: 3,
thumbnail: {
imageData: bytes
}
}
};
const buf = InnertubePayload.encodeBinary(data);
return buf;
}
export function encodeHashtag(hashtag: string): string {
const buf = Hashtag.encodeBinary({
params: {
hashtag,
type: 1
}
});
return encodeURIComponent(u8ToBase64(buf));
}

9
src/types/DashOptions.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface DashOptions {
/**
* Include the storyboards in the DASH manifest when YouTube provides them.
* Not all players support parsing and displaying storyboards.
* If the player you are using doesn't support them you can leave this option disabled.
* They don't get included by default, as this requires making some extra requests while generating the manifest, which can slow down the manifest generation.
*/
include_thumbnails?: boolean
}

37
src/types/FormatUtils.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Format } from '../parser/misc.js';
export type URLTransformer = (url: URL) => URL;
export type FormatFilter = (format: Format) => boolean;
export interface FormatOptions {
/**
* Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
*/
quality?: string;
/**
* Download type, can be: video, audio or video+audio
*/
type?: 'video' | 'audio' | 'video+audio';
/**
* Language code, defaults to 'original'.
*/
language?: string;
/**
* File format, use 'any' to download any format
*/
format?: string;
/**
* InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED
*/
client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
}
export interface DownloadOptions extends FormatOptions {
/**
* Download range, indicates which bytes should be downloaded.
*/
range?: {
start: number;
end: number;
}
}

View File

@@ -18,8 +18,6 @@ interface PlatformShim {
sha1Hash(data: string): Promise<string>;
uuidv4(): string;
eval(code: string, env: Record<string, VMPrimative>): unknown;
DOMParser: typeof globalThis.DOMParser;
serializeDOM: (document: Document) => string;
fetch: FetchFunction;
Request: typeof Request;
Response: typeof Response;

View File

@@ -3,4 +3,5 @@ export type { default as PlatformShim } from './PlatformShim.js';
export * from './Cache.js';
export * from './PlatformShim.js';
export * from './Clients.js';
export * from './Endpoints.js';
export * from './Endpoints.js';
export * from './FormatUtils.js';

View File

@@ -33,6 +33,12 @@ export const OAUTH = Object.freeze({
})
});
export const CLIENTS = Object.freeze({
iOS: {
NAME: 'iOS',
VERSION: '18.06.35',
USER_AGENT: 'com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)',
DEVICE_MODEL: 'iPhone10,6'
},
WEB: {
NAME: 'WEB',
VERSION: '2.20230622.06.00',

249
src/utils/DashManifest.tsx Normal file
View File

@@ -0,0 +1,249 @@
/* eslint-disable tsdoc/syntax */
/** @jsxFactory DashUtils.createElement */
/** @jsxFragmentFactory DashUtils.Fragment */
import type Actions from '../core/Actions.js';
import type Player from '../core/Player.js';
import type { IStreamingData } from '../parser/index.js';
import type { PlayerStoryboardSpec } from '../parser/nodes.js';
import * as DashUtils from './DashUtils.js';
import type { SegmentInfo as FSegmentInfo } from './StreamingInfo.js';
import { getStreamingInfo } from './StreamingInfo.js';
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
import { InnertubeError } from './Utils.js';
interface DashManifestProps {
streamingData: IStreamingData;
transformURL?: URLTransformer;
rejectFormat?: FormatFilter;
cpn?: string;
player?: Player;
actions?: Actions;
storyboards?: PlayerStoryboardSpec;
}
async function OTFSegmentInfo({ info }: { info: FSegmentInfo }) {
if (!info.is_oft) return null;
const template = await info.getSegmentTemplate();
return <segment-template
startNumber="1"
timescale="1000"
initialization={template.init_url}
media={template.media_url}
>
<segment-timeline>
{
template.timeline.map((segment_duration) => (
<s
d={segment_duration.duration}
r={segment_duration.repeat_count}
/>
))
}
</segment-timeline>
</segment-template>;
}
function SegmentInfo({ info }: { info: FSegmentInfo }) {
if (info.is_oft) {
return <OTFSegmentInfo info={info} />;
}
return <>
<base-url>
{info.base_url}
</base-url>
<segment-base indexRange={`${info.index_range.start}-${info.index_range.end}`}>
<initialization range={`${info.init_range.start}-${info.init_range.end}`} />
</segment-base>
</>;
}
function DashManifest({
streamingData,
transformURL,
rejectFormat,
cpn,
player,
actions,
storyboards
}: DashManifestProps) {
const {
duration,
audio_sets,
video_sets,
image_sets
} = getStreamingInfo(streamingData, transformURL, rejectFormat, cpn, player, actions, storyboards);
// XXX: DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
return <mpd
xmlns="urn:mpeg:dash:schema:mpd:2011"
minBufferTime="PT1.500S"
profiles="urn:mpeg:dash:profile:isoff-main:2011"
type="static"
mediaPresentationDuration={`PT${duration}S`}
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
>
<period>
{
audio_sets.map((set, index) => (
<adaptation-set
id={index}
mimeType={set.mime_type}
startWithSAP="1"
subsegmentAlignment="true"
lang={set.language}
codecs={set.codecs}
audioSamplingRate={set.audio_sample_rate}
>
{
set.track_role &&
<role
schemeIdUri="urn:mpeg:dash:role:2011"
value={set.track_role}
/>
}
{
set.track_name &&
<label id={index}>
{set.track_name}
</label>
}
{
set.channels &&
<audio-channel-configuration
schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
value={set.channels}
/>
}
{
set.representations.map((rep) => (
<representation
id={rep.uid}
bandwidth={rep.bitrate}
codecs={rep.codecs}
audioSamplingRate={rep.audio_sample_rate}
>
{
rep.channels &&
<audio-channel-configuration
schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
value={rep.channels}
/>
}
<SegmentInfo info={rep.segment_info} />
</representation>
))
}
</adaptation-set>
))
}
{
video_sets.map((set, index) => (
<adaptation-set
id={index + audio_sets.length}
mimeType={set.mime_type}
startWithSAP="1"
subsegmentAlignment="true"
codecs={set.codecs}
maxPlayoutRate="1"
frameRate={set.fps}
>
{
set.color_info.primaries &&
<essential-property
schemeIdUri="urn:mpeg:mpegB:cicp:ColourPrimaries"
value={set.color_info.primaries}
/>
}
{
set.color_info.transfer_characteristics &&
<essential-property
schemeIdUri="urn:mpeg:mpegB:cicp:TransferCharacteristics"
value={set.color_info.transfer_characteristics}
/>
}
{
set.color_info.matrix_coefficients &&
<essential-property
schemeIdUri="urn:mpeg:mpegB:cicp:MatrixCoefficients"
value={set.color_info.matrix_coefficients}
/>
}
{
set.representations.map((rep) => (
<representation
id={rep.uid}
bandwidth={rep.bitrate}
width={rep.width}
height={rep.height}
codecs={rep.codecs}
frameRate={rep.fps}
>
<SegmentInfo info={rep.segment_info} />
</representation>
))
}
</adaptation-set>
))
}
{
image_sets.map(async (set, index) => {
return <adaptation-set
id={index + audio_sets.length + video_sets.length}
mimeType={await set.getMimeType()}
contentType="image"
>
{
set.representations.map(async (rep) => (
<representation
id={`thumbnails_${rep.thumbnail_width}x${rep.thumbnail_height}`}
bandwidth={await rep.getBitrate()}
width={rep.sheet_width}
height={rep.sheet_height}
>
<essential-property
schemeIdUri="http://dashif.org/thumbnail_tile"
value={`${rep.columns}x${rep.rows}`}
/>
<segment-template
media={rep.template_url}
duration={rep.template_duration}
startNumber="0"
/>
</representation>
))
}
</adaptation-set>;
})
}
</period>
</mpd>;
}
export function toDash(
streaming_data?: IStreamingData,
url_transformer: URLTransformer = (url) => url,
format_filter?: FormatFilter,
cpn?: string,
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec
) {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
return DashUtils.renderToString(
<DashManifest
streamingData={streaming_data}
transformURL={url_transformer}
rejectFormat={format_filter}
cpn={cpn}
player={player}
actions={actions}
storyboards={storyboards}
/>
);
}

104
src/utils/DashUtils.ts Normal file
View File

@@ -0,0 +1,104 @@
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace JSX {
interface IntrinsicElements {
[key: string]: DashProps;
}
}
}
export type DashChild = (DashNode | (DashNode | Promise<DashNode | DashNode[]>) | Promise<DashNode | DashNode[]>);
export interface DashProps {
[key: string]: unknown,
children?: DashChild[]
}
export interface DashNode {
type: string,
props: DashProps,
}
const XML_CHARACTER_MAP = {
'&': '&amp;',
'"': '&quot;',
'\'': '&apos;',
'<': '&lt;',
'>': '&gt;'
} as const;
function escapeXMLString(str: string) {
return str.replace(/([&"<>'])/g, (_, item: keyof typeof XML_CHARACTER_MAP) => {
return XML_CHARACTER_MAP[item];
});
}
function normalizeTag(tag: string) {
if (tag === 'mpd') return 'MPD';
if (tag === 'base-url') return 'BaseURL';
const sections = tag.split('-');
return sections.map((section) => section.charAt(0).toUpperCase() + section.slice(1)).join('');
}
export function createElement(
tagNameOrFunction: string | ((props: DashProps) => DashNode | Promise<DashNode>),
props: { [key: string] : unknown } | null | undefined,
...children: DashChild[]
): DashNode | Promise<DashNode> {
const normalizedChildren = children.flat().map((child) => typeof child === 'string' ? createTextElement(child) : child);
if (typeof tagNameOrFunction === 'function') {
return tagNameOrFunction({ ...props, children: normalizedChildren });
}
return {
type: normalizeTag(tagNameOrFunction),
props: {
...props,
children: normalizedChildren
}
};
}
export function createTextElement(text: string): DashNode {
return {
type: 'TEXT_ELEMENT',
props: { nodeValue: text }
};
}
export async function renderElementToString(element: DashNode): Promise<string> {
if (element.type === 'TEXT_ELEMENT')
return escapeXMLString(typeof element.props.nodeValue === 'string' ? element.props.nodeValue : '');
let dom = `<${element.type}`;
if (element.props) {
const properties = Object.keys(element.props)
.filter((key) => ![ 'children', 'nodeValue' ].includes(key) && element.props[key] !== undefined)
.map((name) => `${name}="${escapeXMLString(`${element.props[name]}`)}"`);
if (properties.length > 0)
dom += ` ${properties.join(' ')}`;
}
if (element.props.children) {
const children = await Promise.all((await Promise.all(element.props.children.flat())).flat().filter((child) => !!child).map((child) => renderElementToString(child)));
if (children.length > 0) {
dom += `>${children.join('')}</${element.type}>`;
return dom;
}
}
return `${dom}/>`;
}
export async function renderToString(root: DashNode | Promise<DashNode>) {
const dom = await renderElementToString(await root);
return `<?xml version="1.0" encoding="utf-8"?>${dom}`;
}
export function Fragment(props: DashProps) {
return props.children;
}

View File

@@ -2,718 +2,193 @@ import type Player from '../core/Player.js';
import type Actions from '../core/Actions.js';
import type Format from '../parser/classes/misc/Format.js';
import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability.js';
import type { YTNode } from '../parser/helpers.js';
import * as Constants from './Constants.js';
import { getStringBetweenStrings, InnertubeError, Platform, streamToIterable } from './Utils.js';
export type URLTransformer = (url: URL) => URL;
export type FormatFilter = (format: Format) => boolean;
export interface FormatOptions {
/**
* Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
*/
quality?: string;
/**
* Download type, can be: video, audio or video+audio
*/
type?: 'video' | 'audio' | 'video+audio';
/**
* Language code, defaults to 'original'.
*/
language?: string;
/**
* File format, use 'any' to download any format
*/
format?: string;
/**
* InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED
*/
client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED';
}
export interface DownloadOptions extends FormatOptions {
/**
* Download range, indicates which bytes should be downloaded.
*/
range?: {
start: number;
end: number;
}
}
class FormatUtils {
static async download(options: DownloadOptions, actions: Actions, playability_status?: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: any;
}, streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}, player?: Player, cpn?: string): Promise<ReadableStream<Uint8Array>> {
if (playability_status?.status === 'UNPLAYABLE')
throw new InnertubeError('Video is unplayable', { error_type: 'UNPLAYABLE' });
if (playability_status?.status === 'LOGIN_REQUIRED')
throw new InnertubeError('Video is login required', { error_type: 'LOGIN_REQUIRED' });
if (!streaming_data)
throw new InnertubeError('Streaming data not available.', { error_type: 'NO_STREAMING_DATA' });
const opts: DownloadOptions = {
quality: '360p',
type: 'video+audio',
format: 'mp4',
range: undefined,
...options
};
const format = this.chooseFormat(opts, streaming_data);
const format_url = format.decipher(player);
// If we're not downloading the video in chunks, we just use fetch once.
if (opts.type === 'video+audio' && !options.range) {
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}`, {
method: 'GET',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
// Throw if the response is not 2xx
if (!response.ok)
throw new InnertubeError('The server responded with a non 2xx status code', { error_type: 'FETCH_FAILED', response });
const body = response.body;
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { error_type: 'FETCH_FAILED', response });
return body;
}
// We need to download in chunks.
const chunk_size = 1048576 * 10; // 10MB
let chunk_start = (options.range ? options.range.start : 0);
let chunk_end = (options.range ? options.range.end : chunk_size);
let must_end = false;
let cancel: AbortController;
const readable_stream = new Platform.shim.ReadableStream<Uint8Array>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
start() { },
pull: async (controller) => {
if (must_end) {
controller.close();
return;
}
if ((chunk_end >= (format.content_length ? format.content_length : 0)) || options.range) {
must_end = true;
}
return new Promise(async (resolve, reject) => {
try {
cancel = new AbortController();
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
method: 'GET',
headers: {
...Constants.STREAM_HEADERS
// XXX: use YouTube's range parameter instead of a Range header.
// Range: `bytes=${chunk_start}-${chunk_end}`
},
signal: cancel.signal
});
const body = response.body;
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response });
for await (const chunk of streamToIterable(body)) {
controller.enqueue(chunk);
}
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
resolve();
return;
} catch (e: any) {
reject(e);
}
});
},
async cancel(reason) {
cancel.abort(reason);
}
}, {
highWaterMark: 1, // TODO: better value?
size(chunk) {
return chunk.byteLength;
}
});
return readable_stream;
}
/**
* Selects the format that best matches the given options.
* @param options - Options
* @param streaming_data - Streaming data
*/
static chooseFormat(options: FormatOptions, streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}): Format {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
const formats = [
...(streaming_data.formats || []),
...(streaming_data.adaptive_formats || [])
];
const requires_audio = options.type ? options.type.includes('audio') : true;
const requires_video = options.type ? options.type.includes('video') : true;
const language = options.language || 'original';
const quality = options.quality || 'best';
let best_width = -1;
const is_best = [ 'best', 'bestefficiency' ].includes(quality);
const use_most_efficient = quality !== 'best';
let candidates = formats.filter((format) => {
if (requires_audio && !format.has_audio)
return false;
if (requires_video && !format.has_video)
return false;
if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
return false;
if (!is_best && format.quality_label !== quality)
return false;
if (best_width < format.width)
best_width = format.width;
return true;
});
if (!candidates.length)
throw new InnertubeError('No matching formats found', { options });
if (is_best && requires_video)
candidates = candidates.filter((format) => format.width === best_width);
if (requires_audio && !requires_video) {
const audio_only = candidates.filter((format) => {
if (language !== 'original') {
return !format.has_video && format.language === language;
}
return !format.has_video && format.is_original;
});
if (audio_only.length > 0) {
candidates = audio_only;
}
}
if (use_most_efficient) {
// Sort by bitrate (lower is better)
candidates.sort((a, b) => a.bitrate - b.bitrate);
} else {
// Sort by bitrate (higher is better)
candidates.sort((a, b) => b.bitrate - a.bitrate);
}
return candidates[0];
}
static async toDash(streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions): Promise<string> {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
let adaptive_formats;
if (format_filter) {
adaptive_formats = streaming_data.adaptive_formats.filter((fmt: Format) => !(format_filter(fmt)));
} else {
adaptive_formats = streaming_data.adaptive_formats;
}
if (!adaptive_formats.length)
throw new InnertubeError('No adaptive formats found');
const length = adaptive_formats[0].approx_duration_ms / 1000;
// DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
const document = new Platform.shim.DOMParser().parseFromString('<?xml version="1.0" encoding="utf-8"?><MPD />', 'application/xml');
const mpd = document.querySelector('MPD') as HTMLElement;
const period = document.createElement('Period');
mpd.replaceWith(this.#el(document, 'MPD', {
xmlns: 'urn:mpeg:dash:schema:mpd:2011',
minBufferTime: 'PT1.500S',
profiles: 'urn:mpeg:dash:profile:isoff-main:2011',
type: 'static',
mediaPresentationDuration: `PT${length}S`,
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'xsi:schemaLocation': 'urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd'
}, [
period
]));
await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions);
return Platform.shim.serializeDOM(document);
}
static #el(document: XMLDocument, tag: string, attrs: Record<string, string | undefined>, children: Node[] = []) {
const el = document.createElement(tag);
for (const [ key, value ] of Object.entries(attrs)) {
value && el.setAttribute(key, value);
}
for (const child of children) {
if (typeof child === 'undefined') continue;
el.appendChild(child);
}
return el;
}
static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const mime_types: string[] = [];
const mime_objects: Format[][] = [ [] ];
formats.forEach((video_format) => {
if ((!video_format.index_range || !video_format.init_range) && !video_format.is_type_otf) {
return;
}
const mime_type = video_format.mime_type;
const mime_type_index = mime_types.indexOf(mime_type);
if (mime_type_index > -1) {
mime_objects[mime_type_index].push(video_format);
} else {
mime_types.push(mime_type);
mime_objects.push([]);
mime_objects[mime_types.length - 1].push(video_format);
}
});
let set_id = 0;
for (let i = 0; i < mime_types.length; i++) {
// When the video has multiple different audio tracks we want to include the extra information in the manifest
if (mime_objects[i][0].has_audio && mime_objects[i][0].audio_track) {
const track_ids: string[] = [];
const track_objects: Format[][] = [ [] ];
mime_objects[i].forEach((format) => {
const id_index = track_ids.indexOf(format.audio_track?.id as string);
if (id_index > -1) {
track_objects[id_index].push(format);
} else {
track_ids.push(format.audio_track?.id as string);
track_objects.push([]);
track_objects[track_ids.length - 1].push(format);
}
});
// The lang attribute has to go on the AdaptationSet element and the Role element goes inside the AdaptationSet too, so we need a separate adaptation set for each language and role
for (let j = 0; j < track_ids.length; j++) {
const first_format = track_objects[j][0];
const children = [];
let role;
if (first_format.audio_track?.audio_is_default) {
role = 'main';
} else if (first_format.is_dubbed) {
role = 'dub';
} else if (first_format.is_descriptive) {
role = 'description';
} else {
role = 'alternate';
}
children.push(
this.#el(document, 'Role', {
schemeIdUri: 'urn:mpeg:dash:role:2011',
value: role
}),
this.#el(document, 'Label', {
id: set_id.toString()
}, [
document.createTextNode(first_format.audio_track?.display_name as string)
])
);
const set = this.#el(document, 'AdaptationSet', {
id: `${set_id++}`,
mimeType: mime_types[i].split(';')[0],
startWithSAP: '1',
subsegmentAlignment: 'true',
lang: first_format.language as string,
// Non-standard attribute used by shaka instead of the standard Label element
label: first_format.audio_track?.display_name as string
}, children);
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, track_objects[j], hoisted);
this.#hoistNumberAttributeIfPossible(set, track_objects[j], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, track_objects[j], hoisted);
period.appendChild(set);
for (const format of track_objects[j]) {
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
} else {
const set = this.#el(document, 'AdaptationSet', {
id: `${set_id++}`,
mimeType: mime_types[i].split(';')[0],
startWithSAP: '1',
subsegmentAlignment: 'true'
});
const color_info = mime_objects[i][0].color_info;
if (typeof color_info !== 'undefined') {
// Section 5.5 Video source metadata signalling https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part7-v5.0.0.pdf
// Section 8 Video code points https://www.itu.int/rec/T-REC-H.273-202107-I/en
// The player.js file was also helpful
if (color_info.primaries) {
let primaries = '';
switch (color_info.primaries) {
case 'BT709':
primaries = '1';
break;
case 'BT2020':
primaries = '9';
break;
}
if (primaries !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:ColourPrimaries',
value: primaries
}));
}
}
if (color_info.transfer_characteristics) {
let transfer_characteristics = '';
switch (color_info.transfer_characteristics) {
case 'BT709':
transfer_characteristics = '1';
break;
case 'BT2020_10':
transfer_characteristics = '14';
break;
case 'SMPTEST2084':
transfer_characteristics = '16';
break;
case 'ARIB_STD_B67':
transfer_characteristics = '18';
break;
}
if (transfer_characteristics !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:TransferCharacteristics',
value: transfer_characteristics
}));
}
}
if (color_info.matrix_coefficients) {
let matrix_coefficients = '';
// This list is incomplete, as the player.js doesn't currently have any code for matrix coefficients,
// So it doesn't have a list like with the other two, so this is just based on what we've seen in responses
switch (color_info.matrix_coefficients) {
case 'BT709':
matrix_coefficients = '1';
break;
case 'BT2020_NCL':
matrix_coefficients = '14';
break;
default: {
const format = mime_objects[i][0];
const url = new URL(format.url as string);
const anonymisedFormat = JSON.parse(JSON.stringify(format));
anonymisedFormat.url = 'REDACTED';
anonymisedFormat.signature_cipher = 'REDACTED';
anonymisedFormat.cipher = 'REDACTED';
console.warn(`YouTube.js toDash(): Unknown matrix coefficients "${color_info.matrix_coefficients}", the DASH manifest is still usuable without this.\n`
+ `Please report it at ${Platform.shim.info.bugs_url} so we can add support for it.\n`
+ `Innertube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat);
}
}
if (matrix_coefficients !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:MatrixCoefficients',
value: matrix_coefficients
}));
}
}
}
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, mime_objects[i], hoisted);
if (mime_objects[i][0].has_audio) {
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, mime_objects[i], hoisted);
} else {
set.setAttribute('maxPlayoutRate', '1');
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'frameRate', 'fps', hoisted);
}
period.appendChild(set);
for (const format of mime_objects[i]) {
if (format.has_video) {
await this.#generateRepresentationVideo(document, set, format, url_transformer, hoisted, cpn, player, actions);
} else {
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
}
}
}
static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1) {
set.setAttribute('codecs', getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"') as string);
hoisted.push('codecs');
}
}
static #hoistNumberAttributeIfPossible(set: Element, formats: Format[], attribute: 'audioSamplingRate' | 'frameRate', property: 'audio_sample_rate' | 'fps', hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) {
set.setAttribute(attribute, formats[0][property]?.toString() as string);
hoisted.push(attribute);
}
}
static #hoistAudioChannelsIfPossible(document: XMLDocument, set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels?.toString() || '2')).size === 1) {
set.appendChild(
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: formats[0].audio_channels?.toString() || '2'
})
);
hoisted.push('AudioChannelConfiguration');
}
}
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const representation = this.#el(document, 'Representation', {
id: format.itag?.toString(),
bandwidth: format.bitrate?.toString(),
width: format.width?.toString(),
height: format.height?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('frameRate')) {
representation.setAttribute('frameRate', format.fps?.toString() as string);
}
set.appendChild(representation);
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
let id;
if (format.audio_track) {
id = `${format.itag?.toString()}-${format.audio_track.id}`;
} else {
id = format.itag?.toString();
}
const representation = this.#el(document, 'Representation', {
id,
bandwidth: format.bitrate?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('audioSamplingRate')) {
representation.setAttribute('audioSamplingRate', format.audio_sample_rate?.toString() as string);
}
if (!hoisted.includes('AudioChannelConfiguration')) {
representation.appendChild(
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: format.audio_channels?.toString() || '2'
})
);
}
set.appendChild(representation);
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) {
if (format.is_type_otf) {
if (!actions) {
throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format });
}
const { resolved_url, segment_durations } = await this.#getOTFSegmentInformation(url, actions);
const segment_elements = [];
for (const segment_duration of segment_durations) {
let attributes;
if (typeof segment_duration.repeat_count === 'undefined') {
attributes = {
d: segment_duration.duration.toString()
};
} else {
attributes = {
d: segment_duration.duration.toString(),
r: segment_duration.repeat_count.toString()
};
}
segment_elements.push(this.#el(document, 'S', attributes));
}
representation.appendChild(
this.#el(document, 'SegmentTemplate', {
startNumber: '1',
timescale: '1000',
initialization: `${resolved_url}&sq=0`,
media: `${resolved_url}&sq=$Number$`
}, [
this.#el(document, 'SegmentTimeline', {}, segment_elements)
])
);
} else {
if (!format.index_range || !format.init_range)
throw new InnertubeError('Index and init ranges not available', { format });
representation.appendChild(
this.#el(document, 'BaseURL', {}, [
document.createTextNode(url)
])
);
representation.appendChild(
this.#el(document, 'SegmentBase', {
indexRange: `${format.index_range.start}-${format.index_range.end}`
}, [
this.#el(document, 'Initialization', {
range: `${format.init_range.start}-${format.init_range.end}`
})
])
);
}
}
static async #getOTFSegmentInformation(url: string, actions: Actions): Promise<{
resolved_url: string,
segment_durations: {
duration: number,
repeat_count?: number
}[]
}> {
// Fetch the first segment as it contains the segment durations which we need to generate the manifest
const response = await actions.session.http.fetch_function(`${url}&rn=0&sq=0`, {
import { InnertubeError, Platform, streamToIterable } from './Utils.js';
import type { IPlayabilityStatus, IStreamingData } from '../parser/index.js';
import type { DownloadOptions, FormatOptions } from '../types/FormatUtils.js';
export async function download(
options: DownloadOptions,
actions: Actions,
playability_status?: IPlayabilityStatus,
streaming_data?: IStreamingData,
player?: Player,
cpn?: string
): Promise<ReadableStream<Uint8Array>> {
if (playability_status?.status === 'UNPLAYABLE')
throw new InnertubeError('Video is unplayable', { error_type: 'UNPLAYABLE' });
if (playability_status?.status === 'LOGIN_REQUIRED')
throw new InnertubeError('Video is login required', { error_type: 'LOGIN_REQUIRED' });
if (!streaming_data)
throw new InnertubeError('Streaming data not available.', { error_type: 'NO_STREAMING_DATA' });
const opts: DownloadOptions = {
quality: '360p',
type: 'video+audio',
format: 'mp4',
range: undefined,
...options
};
const format = chooseFormat(opts, streaming_data);
const format_url = format.decipher(player);
// If we're not downloading the video in chunks, we just use fetch once.
if (opts.type === 'video+audio' && !options.range) {
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}`, {
method: 'GET',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
// Example OTF video: https://www.youtube.com/watch?v=DJ8GQUNUXGM
// Throw if the response is not 2xx
if (!response.ok)
throw new InnertubeError('The server responded with a non 2xx status code', { error_type: 'FETCH_FAILED', response });
// There might have been redirects, if there were we want to write the resolved URL to the manifest
// So that the player doesn't have to follow the redirects every time it requests a segment
const resolved_url = response.url.replace('&rn=0', '').replace('&sq=0', '');
const body = response.body;
// In this function we only need the segment durations and how often the durations are repeated
// The segment count could be useful for other stuff though
// The response body contains a lot of junk but the useful stuff looks like this:
// Segment-Count: 922\r\n' +
// 'Segment-Durations-Ms: 5120(r=920),3600,\r\n'
const response_text = await response.text();
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { error_type: 'FETCH_FAILED', response });
const segment_duration_strings = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')?.split(',');
if (!segment_duration_strings) {
throw new InnertubeError('Failed to extract the segment durations from this OTF stream', { url });
}
const segment_durations = [];
for (const segment_duration_string of segment_duration_strings) {
const trimmed_segment_duration = segment_duration_string.trim();
if (trimmed_segment_duration.length === 0) {
continue;
}
let repeat_count;
const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')');
if (repeat_count_string) {
repeat_count = parseInt(repeat_count_string);
}
segment_durations.push({
duration: parseInt(trimmed_segment_duration),
repeat_count
});
}
return {
resolved_url,
segment_durations
};
return body;
}
// We need to download in chunks.
const chunk_size = 1048576 * 10; // 10MB
let chunk_start = (options.range ? options.range.start : 0);
let chunk_end = (options.range ? options.range.end : chunk_size);
let must_end = false;
let cancel: AbortController;
const readable_stream = new Platform.shim.ReadableStream<Uint8Array>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
start() { },
pull: async (controller) => {
if (must_end) {
controller.close();
return;
}
if ((chunk_end >= (format.content_length ? format.content_length : 0)) || options.range) {
must_end = true;
}
return new Promise(async (resolve, reject) => {
try {
cancel = new AbortController();
const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
method: 'GET',
headers: {
...Constants.STREAM_HEADERS
// XXX: use YouTube's range parameter instead of a Range header.
// Range: `bytes=${chunk_start}-${chunk_end}`
},
signal: cancel.signal
});
const body = response.body;
if (!body)
throw new InnertubeError('Could not get ReadableStream from fetch Response.', { error_type: 'FETCH_FAILED', response });
for await (const chunk of streamToIterable(body)) {
controller.enqueue(chunk);
}
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
resolve();
} catch (e: any) {
reject(e);
}
});
},
async cancel(reason) {
cancel.abort(reason);
}
}, {
highWaterMark: 1, // TODO: better value?
size(chunk) {
return chunk.byteLength;
}
});
return readable_stream;
}
export default FormatUtils;
/**
* Selects the format that best matches the given options.
* @param options - Options
* @param streaming_data - Streaming data
*/
export function chooseFormat(options: FormatOptions, streaming_data?: IStreamingData): Format {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
const formats = [
...(streaming_data.formats || []),
...(streaming_data.adaptive_formats || [])
];
const requires_audio = options.type ? options.type.includes('audio') : true;
const requires_video = options.type ? options.type.includes('video') : true;
const language = options.language || 'original';
const quality = options.quality || 'best';
let best_width = -1;
const is_best = [ 'best', 'bestefficiency' ].includes(quality);
const use_most_efficient = quality !== 'best';
let candidates = formats.filter((format) => {
if (requires_audio && !format.has_audio)
return false;
if (requires_video && !format.has_video)
return false;
if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
return false;
if (!is_best && format.quality_label !== quality)
return false;
if (best_width < format.width)
best_width = format.width;
return true;
});
if (!candidates.length)
throw new InnertubeError('No matching formats found', { options });
if (is_best && requires_video)
candidates = candidates.filter((format) => format.width === best_width);
if (requires_audio && !requires_video) {
const audio_only = candidates.filter((format) => {
if (language !== 'original') {
return !format.has_video && format.language === language;
}
return !format.has_video && format.is_original;
});
if (audio_only.length > 0) {
candidates = audio_only;
}
}
if (use_most_efficient) {
// Sort by bitrate (lower is better)
candidates.sort((a, b) => a.bitrate - b.bitrate);
} else {
// Sort by bitrate (higher is better)
candidates.sort((a, b) => b.bitrate - a.bitrate);
}
return candidates[0];
}
export { toDash } from './DashManifest.js';

View File

@@ -95,6 +95,8 @@ export default class HTTPClient {
if (Platform.shim.server) {
if (n_body.context.client.clientName === 'ANDROID' || n_body.context.client.clientName === 'ANDROID_MUSIC') {
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
} else if (n_body.context.client.clientName === 'iOS') {
request_headers.set('User-Agent', Constants.CLIENTS.iOS.USER_AGENT);
}
}
@@ -139,7 +141,8 @@ export default class HTTPClient {
// Check if 2xx
if (response.ok) {
return response;
} throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, await response.text());
}
throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, await response.text());
}
#adjustContext(ctx: Context, client: string): void {
@@ -157,6 +160,12 @@ export default class HTTPClient {
}
switch (client) {
case 'iOS':
ctx.client.deviceModel = Constants.CLIENTS.iOS.DEVICE_MODEL;
ctx.client.clientVersion = Constants.CLIENTS.iOS.VERSION;
ctx.client.clientName = Constants.CLIENTS.iOS.NAME;
ctx.client.platform = 'MOBILE';
break;
case 'YTMUSIC':
ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION;
ctx.client.clientName = Constants.CLIENTS.YTMUSIC.NAME;

633
src/utils/StreamingInfo.ts Normal file
View File

@@ -0,0 +1,633 @@
import type Actions from '../core/Actions.js';
import type Player from '../core/Player.js';
import type { StoryboardData } from '../parser/classes/PlayerStoryboardSpec.js';
import type { IStreamingData } from '../parser/index.js';
import type { Format } from '../parser/misc.js';
import type { PlayerStoryboardSpec } from '../parser/nodes.js';
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
import { InnertubeError, Platform, getStringBetweenStrings } from './Utils.js';
import { Constants } from './index.js';
export interface StreamingInfo {
duration: number;
audio_sets: AudioSet[];
video_sets: VideoSet[];
image_sets: ImageSet[];
}
export interface AudioSet {
mime_type: string;
language?: string;
codecs?: string;
audio_sample_rate?: number;
track_name?: string;
track_role?: 'main' | 'dub' | 'description' | 'alternate';
channels?: number;
representations: AudioRepresentation[];
}
export interface Range {
start: number;
end: number;
}
export type SegmentInfo = {
is_oft: false,
base_url: string;
index_range: Range;
init_range: Range;
} | {
is_oft: true,
getSegmentTemplate(): Promise<SegmentTemplate>
}
export interface Segment {
duration: number,
repeat_count?: number
}
export interface SegmentTemplate {
init_url: string,
media_url: string,
timeline: Segment[]
}
export interface AudioRepresentation {
uid: string;
bitrate: number;
codecs?: string;
audio_sample_rate?: number;
channels?: number;
segment_info: SegmentInfo;
}
export interface VideoSet {
mime_type: string;
color_info: ColorInfo;
codecs?: string;
fps?: number;
representations: VideoRepresentation[]
}
export interface VideoRepresentation {
uid: string;
bitrate: number;
width: number;
height: number;
fps?: number;
codecs?: string;
segment_info: SegmentInfo;
}
export interface ColorInfo {
primaries?: '1' | '9',
transfer_characteristics?: '1' | '14' | '16' | '18',
matrix_coefficients?: '1' | '14'
}
export interface ImageSet {
probable_mime_type: string;
/**
* Sometimes youtube returns webp instead of jpg despite the file extension being jpg
* So we need to update the mime type to reflect the actual mime type of the response
*/
getMimeType(): Promise<string>;
representations: ImageRepresentation[]
}
export interface ImageRepresentation {
uid: string;
getBitrate(): Promise<number>;
sheet_width: number;
sheet_height: number;
thumbnail_width: number;
thumbnail_height: number;
rows: number;
columns: number;
template_url: string;
template_duration: number;
getURL(n: number): string;
}
function getFormatGroupings(formats: Format[]) {
const group_info = new Map<string, Format[]>();
const has_multiple_audio_tracks = formats.some((fmt) => !!fmt.audio_track);
for (const format of formats) {
if ((!format.index_range || !format.init_range) && !format.is_type_otf) {
continue;
}
const mime_type = format.mime_type.split(';')[0];
// Codec without any profile or level information
const just_codec = getStringBetweenStrings(format.mime_type, 'codecs="', '"')?.split('.')[0];
// HDR videos have both SDR and HDR vp9 formats, so we want to stick them in different groups
const color_info = format.color_info ? Object.values(format.color_info).join('-') : '';
const audio_track_id = format.audio_track?.id || '';
const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}`;
if (!group_info.has(group_id)) {
group_info.set(group_id, []);
}
group_info.get(group_id)?.push(format);
}
return {
groups: Array.from(group_info.values()),
has_multiple_audio_tracks
};
}
function hoistCodecsIfPossible(formats: Format[], hoisted: string[]) {
if (
formats.length > 1 &&
new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1
) {
hoisted.push('codecs');
return getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"');
}
}
function hoistNumberAttributeIfPossible(
formats: Format[],
property: 'audio_sample_rate' | 'fps',
hoisted: string[]
) {
if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) {
hoisted.push(property);
return Number(formats[0][property]);
}
}
function hoistAudioChannelsIfPossible(formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels || 2)).size === 1) {
hoisted.push('AudioChannelConfiguration');
return formats[0].audio_channels;
}
}
async function getOTFSegmentTemplate(url: string, actions: Actions): Promise<SegmentTemplate> {
// Fetch the first segment as it contains the segment durations which we need to generate the manifest
const response = await actions.session.http.fetch_function(`${url}&rn=0&sq=0`, {
method: 'GET',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
// Example OTF video: https://www.youtube.com/watch?v=DJ8GQUNUXGM
// There might have been redirects, if there were we want to write the resolved URL to the manifest
// So that the player doesn't have to follow the redirects every time it requests a segment
const resolved_url = response.url.replace('&rn=0', '').replace('&sq=0', '');
// In this function we only need the segment durations and how often the durations are repeated
// The segment count could be useful for other stuff though
// The response body contains a lot of junk but the useful stuff looks like this:
// Segment-Count: 922\r\n' +
// 'Segment-Durations-Ms: 5120(r=920),3600,\r\n'
const response_text = await response.text();
const segment_duration_strings = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')?.split(',');
if (!segment_duration_strings) {
throw new InnertubeError('Failed to extract the segment durations from this OTF stream', { url });
}
const segment_durations = [];
for (const segment_duration_string of segment_duration_strings) {
const trimmed_segment_duration = segment_duration_string.trim();
if (trimmed_segment_duration.length === 0) {
continue;
}
let repeat_count;
const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')');
if (repeat_count_string) {
repeat_count = parseInt(repeat_count_string);
}
segment_durations.push({
duration: parseInt(trimmed_segment_duration),
repeat_count
});
}
return {
init_url: `${resolved_url}&sq=0`,
media_url: `${resolved_url}&sq=$Number$`,
timeline: segment_durations
};
}
function getSegmentInfo(
format: Format,
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const transformed_url = url_transformer(url).toString();
if (format.is_type_otf) {
if (!actions)
throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format });
const info: SegmentInfo = {
is_oft: true,
getSegmentTemplate() {
return getOTFSegmentTemplate(transformed_url, actions);
}
};
return info;
}
if (!format.index_range || !format.init_range)
throw new InnertubeError('Index and init ranges not available', { format });
const info: SegmentInfo = {
is_oft: false,
base_url: transformed_url,
index_range: format.index_range,
init_range: format.init_range
};
return info;
}
function getAudioRepresentation(
format: Format,
hoisted: string[],
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const rep: AudioRepresentation = {
uid: format.audio_track ? `${format.itag}-${format.audio_track.id}` : format.itag.toString(),
bitrate: format.bitrate,
codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined,
audio_sample_rate: !hoisted.includes('audio_sample_rate') ? format.audio_sample_rate : undefined,
channels: !hoisted.includes('AudioChannelConfiguration') ? format.audio_channels || 2 : undefined,
segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn)
};
return rep;
}
function getTrackRole(format: Format) {
const { audio_track } = format;
if (!audio_track)
return;
if (audio_track.audio_is_default)
return 'main';
if (format.is_dubbed)
return 'dub';
if (format.is_descriptive)
return 'description';
return 'alternate';
}
function getAudioSet(
formats: Format[],
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
) {
const first_format = formats[0];
const { audio_track } = first_format;
const hoisted: string[] = [];
const set: AudioSet = {
mime_type: first_format.mime_type.split(';')[0],
language: first_format.language ?? undefined,
codecs: hoistCodecsIfPossible(formats, hoisted),
audio_sample_rate: hoistNumberAttributeIfPossible(formats, 'audio_sample_rate', hoisted),
track_name: audio_track?.display_name,
track_role: getTrackRole(first_format),
channels: hoistAudioChannelsIfPossible(formats, hoisted),
representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn))
};
return set;
}
const COLOR_PRIMARIES: Record<string, ColorInfo['primaries']> = {
BT709: '1',
BT2020: '9'
};
const COLOR_TRANSFER_CHARACTERISTICS: Record<string, ColorInfo['transfer_characteristics']> = {
BT709: '1',
BT2020_10: '14',
SMPTEST2084: '16',
ARIB_STD_B67: '18'
};
// This list is incomplete, as the player.js doesn't currently have any code for matrix coefficients,
// So it doesn't have a list like with the other two, so this is just based on what we've seen in responses
const COLOR_MATRIX_COEFFICIENTS: Record<string, ColorInfo['matrix_coefficients']> = {
BT709: '1',
BT2020_NCL: '14'
};
function getColorInfo(format: Format) {
// Section 5.5 Video source metadata signalling https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part7-v5.0.0.pdf
// Section 8 Video code points https://www.itu.int/rec/T-REC-H.273-202107-I/en
// The player.js file was also helpful
const color_info = format.color_info;
const primaries =
color_info?.primaries ? COLOR_PRIMARIES[color_info.primaries] : undefined;
const transfer_characteristics =
color_info?.transfer_characteristics ? COLOR_TRANSFER_CHARACTERISTICS[color_info.transfer_characteristics] : undefined;
const matrix_coefficients =
color_info?.matrix_coefficients ? COLOR_MATRIX_COEFFICIENTS[color_info.matrix_coefficients] : undefined;
if (color_info?.matrix_coefficients && !matrix_coefficients) {
const url = new URL(format.url as string);
const anonymisedFormat = JSON.parse(JSON.stringify(format));
anonymisedFormat.url = 'REDACTED';
anonymisedFormat.signature_cipher = 'REDACTED';
anonymisedFormat.cipher = 'REDACTED';
console.warn(`YouTube.js toDash(): Unknown matrix coefficients "${color_info.matrix_coefficients}", the DASH manifest is still usuable without this.\n`
+ `Please report it at ${Platform.shim.info.bugs_url} so we can add support for it.\n`
+ `Innertube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat);
}
const info: ColorInfo = {
primaries,
transfer_characteristics,
matrix_coefficients
};
return info;
}
function getVideoRepresentation(
format: Format,
url_transformer: URLTransformer,
hoisted: string[],
player?: Player,
actions?: Actions,
cpn?: string
) {
const rep: VideoRepresentation = {
uid: format.itag.toString(),
bitrate: format.bitrate,
width: format.width,
height: format.height,
codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined,
fps: !hoisted.includes('fps') ? format.fps : undefined,
segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn)
};
return rep;
}
function getVideoSet(
formats: Format[],
url_transformer: URLTransformer,
player?: Player,
actions?: Actions,
cpn?: string
) {
const first_format = formats[0];
const color_info = getColorInfo(first_format);
const hoisted: string[] = [];
const set: VideoSet = {
mime_type: first_format.mime_type.split(';')[0],
color_info,
codecs: hoistCodecsIfPossible(formats, hoisted),
fps: hoistNumberAttributeIfPossible(formats, 'fps', hoisted),
representations: formats.map((format) => getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn))
};
return set;
}
function getStoryboardInfo(
storyboards: PlayerStoryboardSpec
) {
const mime_info = new Map<string, StoryboardData[]>();
for (const storyboard of storyboards.boards) {
const extension = new URL(storyboard.template_url).pathname.split('.').pop();
const mime_type = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
if (!mime_info.has(mime_type)) {
mime_info.set(mime_type, []);
}
mime_info.get(mime_type)?.push(storyboard);
}
return mime_info;
}
interface SharedStoryboardResponse {
response?: Promise<Response>
}
async function getStoryboardMimeType(
actions: Actions,
board: StoryboardData,
transform_url: URLTransformer,
probable_mime_type: string,
shared_response: SharedStoryboardResponse
) {
const url = board.template_url;
const req_url = transform_url(new URL(url.replace('$M', '0')));
const res_promise = shared_response.response ? shared_response.response : actions.session.http.fetch_function(req_url, {
method: 'HEAD',
headers: Constants.STREAM_HEADERS
});
shared_response.response = res_promise;
const res = await res_promise;
return res.headers.get('Content-Type') || probable_mime_type;
}
async function getStoryboardBitrate(
actions: Actions,
board: StoryboardData,
shared_response: SharedStoryboardResponse
) {
const url = board.template_url;
const response_promises: Promise<Response>[] = [];
// Set a limit so we don't take forever for long videos
const request_limit = Math.min(board.storyboard_count, 10);
for (let i = 0; i < request_limit; i++) {
const req_url = new URL(url.replace('$M', i.toString()));
const response_promise =
i === 0 && shared_response.response ?
shared_response.response :
actions.session.http.fetch_function(req_url, {
method: 'HEAD',
headers: Constants.STREAM_HEADERS
});
if (i === 0)
shared_response.response = response_promise;
response_promises.push(response_promise);
}
// Run the requests in parallel to avoid causing too much delay
const responses = await Promise.all(response_promises);
const content_lengths = [];
for (const response of responses) {
content_lengths.push(parseInt(response.headers.get('Content-Length') || '0'));
}
// This is a rough estimate, so it probably won't reflect that actual peak bitrate
// Hopefully it's close enough, because figuring out the actual peak bitrate would require downloading and analysing all storyboard tiles
const bandwidth = Math.ceil((Math.max(...content_lengths) / (board.rows * board.columns)) * 8);
return bandwidth;
}
function getImageRepresentation(
duration: number,
actions: Actions,
board: StoryboardData,
transform_url: URLTransformer,
shared_response: SharedStoryboardResponse
) {
const url = board.template_url;
const template_url = new URL(url.replace('$M', '$Number$'));
const rep: ImageRepresentation = {
uid: `thumbnails_${board.thumbnail_width}x${board.thumbnail_height}`,
getBitrate() {
return getStoryboardBitrate(actions, board, shared_response);
},
sheet_width: board.thumbnail_width * board.columns,
sheet_height: board.thumbnail_height * board.rows,
thumbnail_height: board.thumbnail_height,
thumbnail_width: board.thumbnail_width,
rows: board.rows,
columns: board.columns,
template_duration: duration / board.storyboard_count,
template_url: transform_url(template_url).toString(),
getURL(n) {
return template_url.toString().replace('$Number$', n.toString());
}
};
return rep;
}
function getImageSets(
duration: number,
actions: Actions,
storyboards: PlayerStoryboardSpec,
transform_url: URLTransformer
) {
const mime_info = getStoryboardInfo(storyboards);
const shared_response: SharedStoryboardResponse = {};
return Array.from(mime_info.entries()).map<ImageSet>(([ type, boards ]) => ({
probable_mime_type: type,
getMimeType() {
return getStoryboardMimeType(actions, boards[0], transform_url, type, shared_response);
},
representations: boards.map((board) => getImageRepresentation(duration, actions, board, transform_url, shared_response))
}));
}
export function getStreamingInfo(
streaming_data?: IStreamingData,
url_transformer: URLTransformer = (url) => url,
format_filter?: FormatFilter,
cpn?: string,
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec
) {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
const formats = format_filter ?
streaming_data.adaptive_formats.filter((fmt) => !format_filter(fmt)) :
streaming_data.adaptive_formats;
const duration = formats[0].approx_duration_ms / 1000;
const {
groups,
has_multiple_audio_tracks
} = getFormatGroupings(formats);
const {
video_groups,
audio_groups
} = groups.reduce((acc, formats) => {
if (formats[0].has_audio) {
// Some videos with multiple audio tracks, have a broken one, that doesn't have any audio track information
// It seems to be the same as default audio track but broken
// We want to ignore it, as it messes up audio track selection in players and YouTube ignores it too
// At the time of writing, this video has a broken audio track: https://youtu.be/UJeSWbR6W04
if (has_multiple_audio_tracks && !formats[0].audio_track)
return acc;
acc.audio_groups.push(formats);
return acc;
}
acc.video_groups.push(formats);
return acc;
}, {
video_groups: [] as Format[][],
audio_groups: [] as Format[][]
});
const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn));
const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn));
// XXX: We need to make requests to get the image sizes, so we'll skip the storyboards if we don't have an Actions instance
const image_sets = storyboards && actions ? getImageSets(duration, actions, storyboards, url_transformer) : [];
const info : StreamingInfo = {
duration,
audio_sets,
video_sets,
image_sets
};
return info;
}

View File

@@ -4,8 +4,7 @@ export * as Constants from './Constants.js';
export { default as EventEmitter } from './EventEmitterLike.js';
export { default as FormatUtils } from './FormatUtils.js';
export * from './FormatUtils.js';
export * as FormatUtils from './FormatUtils.js';
export { default as HTTPClient } from './HTTPClient.js';
export * from './HTTPClient.js';

View File

@@ -1,5 +1,5 @@
import { createWriteStream, existsSync } from 'node:fs';
import { Innertube, IBrowseResponse, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
describe('YouTube.js Tests', () => {
let innertube: Innertube;
@@ -19,6 +19,30 @@ describe('YouTube.js Tests', () => {
expect(info.basic_info.id).toBe('bUHZ2k9DYHY');
});
describe('Innertube#getBasicInfo', () => {
test('Format#language multiple audio tracks', async () => {
const info = await innertube.getBasicInfo('Kn56bMZ9OE8')
expect(info.streaming_data).toBeDefined()
const default_track_format = info.streaming_data?.adaptive_formats.find(format => format.audio_track?.audio_is_default)
expect(default_track_format).toBeDefined()
expect(default_track_format?.language).toBe('en')
// check that the language is properly propagated to the non-dash formats
expect(info.streaming_data?.formats[0].language).toBe('en')
})
test('Format#language single audio track with captions', async () => {
const info = await innertube.getBasicInfo('gisdyTBMNyQ')
expect(info.streaming_data).toBeDefined()
const audio_format = info.streaming_data?.adaptive_formats.find(format => format.has_audio)
expect(audio_format).toBeDefined()
expect(audio_format?.language).toBe('en')
expect(info.streaming_data?.formats[0].language).toBe('en')
})
})
test('Innertube#search', async () => {
const search = await innertube.search('Anton Petrov');
expect(search.page).toBeDefined();

View File

@@ -13,11 +13,11 @@
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ESNext", "DOM", "DOM.Iterable"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
"jsx": "react", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
"jsxFactory": "DashUtils.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
"jsxFragmentFactory": "DashUtils.Fragment", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
@@ -102,6 +102,7 @@
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js"
],
"exclude": [