mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 17:42:18 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052632314b | ||
|
|
22a38c0762 | ||
|
|
f7614634b6 | ||
|
|
bf1510b235 | ||
|
|
815e54b854 | ||
|
|
f7666051f6 | ||
|
|
494ee8776a | ||
|
|
87ed3960ff | ||
|
|
eb3cca1e2e | ||
|
|
9971ffe021 | ||
|
|
7949b3df66 | ||
|
|
aa385142e4 | ||
|
|
6c8a916f0f | ||
|
|
31d27b1bca | ||
|
|
cb37c6a17b | ||
|
|
1ff3e1a440 | ||
|
|
46fe18b763 | ||
|
|
0dda97e0b0 | ||
|
|
e370116092 | ||
|
|
3bc53a8c12 | ||
|
|
74e1a5e068 | ||
|
|
0fa5a859ae | ||
|
|
02a111250a | ||
|
|
c1886f9a83 | ||
|
|
5f4cbdb904 | ||
|
|
d91695a9ec | ||
|
|
137464ca66 | ||
|
|
6997982cf2 | ||
|
|
18cbc8c038 | ||
|
|
30ff087587 | ||
|
|
1a034733f6 | ||
|
|
c477b824c0 | ||
|
|
7e5c3648c1 | ||
|
|
bdd98a3b9b | ||
|
|
06750aaa74 | ||
|
|
708c5f7394 | ||
|
|
a9cdbf7010 | ||
|
|
b50d1ef67d | ||
|
|
555d257459 | ||
|
|
2aef67876e | ||
|
|
ae2557d15c | ||
|
|
8c688efb4a | ||
|
|
cffa868c6e | ||
|
|
f267fcd8be | ||
|
|
23c22a93c4 | ||
|
|
1ca20836bf | ||
|
|
5f058e69ae | ||
|
|
3500e92632 | ||
|
|
3f57c2fa5c | ||
|
|
7528ebdb60 | ||
|
|
5e3846259f | ||
|
|
222dfce6bb | ||
|
|
83cbfd631b | ||
|
|
4f9427d752 | ||
|
|
07c1b3e0e5 | ||
|
|
89548ad48a | ||
|
|
519be72445 | ||
|
|
e434bb2632 | ||
|
|
a11e5962c6 | ||
|
|
77b39c79ee | ||
|
|
7c530d30ee | ||
|
|
1e07a184ff | ||
|
|
5de7b24dc5 | ||
|
|
01fd1ee72a | ||
|
|
84b4f1efd1 | ||
|
|
046103a4d8 |
144
CHANGELOG.md
144
CHANGELOG.md
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Channel:** Add `getPodcasts()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **Channel:** Add `getReleases()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
* **parser:** Add `Quiz` ([#437](https://github.com/LuanRT/YouTube.js/issues/437)) ([cffa868](https://github.com/LuanRT/YouTube.js/commit/cffa868c6eeb579047653fac65da8e913fb3c621))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Playlist:** Parse `PlaylistCustomThumbnail` for `thumbnail_renderer` ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
|
||||
|
||||
## [5.3.0](https://github.com/LuanRT/YouTube.js/compare/v5.2.1...v5.3.0) (2023-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **toDash:** Add color information ([#430](https://github.com/LuanRT/YouTube.js/issues/430)) ([3500e92](https://github.com/LuanRT/YouTube.js/commit/3500e926327d560b1db036bfe503c276b91922ac))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Format:** Cleanup the xtags parsing ([#434](https://github.com/LuanRT/YouTube.js/issues/434)) ([1ca2083](https://github.com/LuanRT/YouTube.js/commit/1ca20836bf343c78461fab7ad3b71db2b96e65c3))
|
||||
* **toDash:** Hoist duplicates from Representation to AdaptationSet ([#431](https://github.com/LuanRT/YouTube.js/issues/431)) ([5f058e6](https://github.com/LuanRT/YouTube.js/commit/5f058e69ae8594491133f7f96287bea4137f7822))
|
||||
|
||||
## [5.2.1](https://github.com/LuanRT/YouTube.js/compare/v5.2.0...v5.2.1) (2023-07-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect node parser implementations ([#428](https://github.com/LuanRT/YouTube.js/issues/428)) ([222dfce](https://github.com/LuanRT/YouTube.js/commit/222dfce6bbd13b2cd80ae11540cbc0edd9053fc5))
|
||||
|
||||
## [5.2.0](https://github.com/LuanRT/YouTube.js/compare/v5.1.0...v5.2.0) (2023-06-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **VideoDetails:** Add is_post_live_dvr property ([#411](https://github.com/LuanRT/YouTube.js/issues/411)) ([a11e596](https://github.com/LuanRT/YouTube.js/commit/a11e5962c6eb73b14623a9de1e6c8c2534146b1e))
|
||||
* **ytmusic:** Add support for YouTube Music mood filters ([#404](https://github.com/LuanRT/YouTube.js/issues/404)) ([77b39c7](https://github.com/LuanRT/YouTube.js/commit/77b39c79ee0768eb203b7d47ea81286d470c21f2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **OAuth:** client identity matching ([#421](https://github.com/LuanRT/YouTube.js/issues/421)) ([07c1b3e](https://github.com/LuanRT/YouTube.js/commit/07c1b3e0e57cb1fa42e4772775bfd1437bbc731f))
|
||||
* **PlayerEndpoint:** Use different player params ([#419](https://github.com/LuanRT/YouTube.js/issues/419)) ([519be72](https://github.com/LuanRT/YouTube.js/commit/519be72445b7ff392b396e16bcb1dc05c7df8976))
|
||||
* **Playlist:** Add thumbnail_renderer on Playlist when response includes it ([#424](https://github.com/LuanRT/YouTube.js/issues/424)) ([4f9427d](https://github.com/LuanRT/YouTube.js/commit/4f9427d752e89faec8dd1c4fd7a9607dca998c7a))
|
||||
* **VideoInfo.ts:** reimplement `get music_tracks` ([#409](https://github.com/LuanRT/YouTube.js/issues/409)) ([e434bb2](https://github.com/LuanRT/YouTube.js/commit/e434bb2632fe2b20aab6f1e707a93ca76f9d5c91))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **Search:** Speed up results parsing ([#408](https://github.com/LuanRT/YouTube.js/issues/408)) ([1e07a18](https://github.com/LuanRT/YouTube.js/commit/1e07a184ffaff508ad5ba869cb5e7dc9f095f744))
|
||||
* **toDash:** Speed up format filtering ([#405](https://github.com/LuanRT/YouTube.js/issues/405)) ([5de7b24](https://github.com/LuanRT/YouTube.js/commit/5de7b24dc55fca3eb8fccc6fa30d3c2cd60b8184))
|
||||
|
||||
## [5.1.0](https://github.com/LuanRT/YouTube.js/compare/v5.0.4...v5.1.0) (2023-05-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ReelItem:** Add accessibility label ([#401](https://github.com/LuanRT/YouTube.js/issues/401)) ([046103a](https://github.com/LuanRT/YouTube.js/commit/046103a4d8af09fafefab6e9f971184eeca75c2e))
|
||||
* **toDash:** Add audio track labels to the manifest when available ([#402](https://github.com/LuanRT/YouTube.js/issues/402)) ([84b4f1e](https://github.com/LuanRT/YouTube.js/commit/84b4f1efd111321e4f3e5a87844790c4ec9b0b52))
|
||||
|
||||
## [5.0.4](https://github.com/LuanRT/YouTube.js/compare/v5.0.3...v5.0.4) (2023-05-10)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ This page lists the collaborators who have contributed to the development and su
|
||||
|
||||
## [LuanRT](https://github.com/LuanRT)
|
||||
[](https://github.com/sponsors/LuanRT)
|
||||
[](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.
|
||||
@@ -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.
|
||||
@@ -542,6 +542,8 @@ Retrieves contents for a given channel.
|
||||
- `<channel>#getVideos()`
|
||||
- `<channel>#getShorts()`
|
||||
- `<channel>#getLiveStreams()`
|
||||
- `<channel>#getReleases()`
|
||||
- `<channel>#getPodcasts()`
|
||||
- `<channel>#getPlaylists()`
|
||||
- `<channel>#getHome()`
|
||||
- `<channel>#getCommunity()`
|
||||
@@ -786,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]
|
||||
|
||||
|
||||
2
deno.ts
2
deno.ts
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -13,6 +13,6 @@
|
||||
"vite": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dashjs": "^4.4.0"
|
||||
"shaka-player": "^4.3.8"
|
||||
}
|
||||
}
|
||||
423
examples/browser/web/src/assets/player.css
Normal file
423
examples/browser/web/src/assets/player.css
Normal 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;
|
||||
}
|
||||
}
|
||||
135
examples/browser/web/src/assets/style.css
Normal file
135
examples/browser/web/src/assets/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
5971
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "5.0.4",
|
||||
"version": "6.0.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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
48
scripts/get-agents.mjs
Normal 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[] };`);
|
||||
@@ -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';
|
||||
|
||||
@@ -96,7 +96,7 @@ export default class OAuth {
|
||||
client_id: this.#identity.client_id,
|
||||
scope: Constants.OAUTH.SCOPE,
|
||||
device_id: Platform.shim.uuidv4(),
|
||||
model_name: Constants.OAUTH.MODEL_NAME
|
||||
device_model: Constants.OAUTH.MODEL_NAME
|
||||
};
|
||||
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export const PATH = '/player';
|
||||
* @returns The payload.
|
||||
*/
|
||||
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
const is_android =
|
||||
opts.client === 'ANDROID' ||
|
||||
opts.client === 'YTMUSIC_ANDROID' ||
|
||||
opts.client === 'YTSTUDIO_ANDROID';
|
||||
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
@@ -37,8 +42,8 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
|
||||
...{
|
||||
client: opts.client,
|
||||
playlistId: opts.playlist_id,
|
||||
// Workaround streaming URLs returning 403 when using Android clients and throttling in web clients.
|
||||
params: '8AEB'
|
||||
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
|
||||
params: is_android ? '2AMBCgIQBg' : opts.params
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
17
src/parser/classes/CarouselLockup.ts
Normal file
17
src/parser/classes/CarouselLockup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type ObservedArray, YTNode } from '../helpers.js';
|
||||
import InfoRow from './InfoRow.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import CompactVideo from './CompactVideo.js';
|
||||
|
||||
export default class CarouselLockup extends YTNode {
|
||||
static type = 'CarouselLockup';
|
||||
|
||||
info_rows: ObservedArray<InfoRow>;
|
||||
video_lockup?: CompactVideo | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.info_rows = Parser.parseArray(data.infoRows, InfoRow);
|
||||
this.video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
|
||||
}
|
||||
}
|
||||
16
src/parser/classes/ContentPreviewImageView.ts
Normal file
16
src/parser/classes/ContentPreviewImageView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/parser/classes/DynamicTextView.ts
Normal file
13
src/parser/classes/DynamicTextView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/parser/classes/EngagementPanelSectionList.ts
Normal file
26
src/parser/classes/EngagementPanelSectionList.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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';
|
||||
|
||||
export default class EngagementPanelSectionList extends YTNode {
|
||||
static type = 'EngagementPanelSectionList';
|
||||
|
||||
header: EngagementPanelTitleHeader | null;
|
||||
content: SectionList | ContinuationItem | StructuredDescriptionContent | MacroMarkersList | null;
|
||||
target_id?: string;
|
||||
panel_identifier?: string;
|
||||
visibility?: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
|
||||
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent, MacroMarkersList ]);
|
||||
this.panel_identifier = data.panelIdentifier;
|
||||
this.target_id = data.targetId;
|
||||
this.visibility = data.visibility;
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/EngagementPanelTitleHeader.ts
Normal file
17
src/parser/classes/EngagementPanelTitleHeader.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Button from './Button.js';
|
||||
|
||||
export default class EngagementPanelTitleHeader extends YTNode {
|
||||
static type = 'EngagementPanelTitleHeader';
|
||||
|
||||
title: Text;
|
||||
visibility_button: Button | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.visibility_button = Parser.parseItem(data.visibilityButton, Button);
|
||||
}
|
||||
}
|
||||
22
src/parser/classes/ExpandableVideoDescriptionBody.ts
Normal file
22
src/parser/classes/ExpandableVideoDescriptionBody.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Text } from '../misc.js';
|
||||
|
||||
import type { RawNode } from '../index.js';
|
||||
|
||||
export default class ExpandableVideoDescriptionBody extends YTNode {
|
||||
static type = 'ExpandableVideoDescriptionBody';
|
||||
|
||||
show_more_text: Text;
|
||||
show_less_text: Text;
|
||||
attributed_description_body_text?: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.show_more_text = new Text(data.showMoreText);
|
||||
this.show_less_text = new Text(data.showLessText);
|
||||
|
||||
if (Reflect.has(data, 'attributedDescriptionBodyText')) {
|
||||
this.attributed_description_body_text = data.attributedDescriptionBodyText?.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/parser/classes/Factoid.ts
Normal file
18
src/parser/classes/Factoid.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { type RawNode } from '../index.js';
|
||||
import { Text } from '../misc.js';
|
||||
|
||||
export default class Factoid extends YTNode {
|
||||
static type = 'Factoid';
|
||||
|
||||
label: Text;
|
||||
value: Text;
|
||||
accessibility_text: String;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.label = new Text(data.label);
|
||||
this.value = new Text(data.value);
|
||||
this.accessibility_text = data.accessibilityText;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
28
src/parser/classes/HashtagTile.ts
Normal file
28
src/parser/classes/HashtagTile.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/parser/classes/IncludingResultsFor.ts
Normal file
25
src/parser/classes/IncludingResultsFor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/parser/classes/InfoRow.ts
Normal file
29
src/parser/classes/InfoRow.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Text } from '../misc.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
|
||||
export default class InfoRow extends YTNode {
|
||||
static type = 'InfoRow';
|
||||
|
||||
title: Text;
|
||||
default_metadata?: Text;
|
||||
expanded_metadata?: Text;
|
||||
info_row_expand_status_key?: String;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
|
||||
if (Reflect.has(data, 'defaultMetadata')) {
|
||||
this.default_metadata = new Text(data.defaultMetadata);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'expandedMetadata')) {
|
||||
this.expanded_metadata = new Text(data.expandedMetadata);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'infoRowExpandStatusKey')) {
|
||||
this.info_row_expand_status_key = data.infoRowExpandStatusKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/MacroMarkersInfoItem.ts
Normal file
17
src/parser/classes/MacroMarkersInfoItem.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/parser/classes/MacroMarkersList.ts
Normal file
18
src/parser/classes/MacroMarkersList.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
44
src/parser/classes/MusicMultiRowListItem.ts
Normal file
44
src/parser/classes/MusicMultiRowListItem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
src/parser/classes/PageHeader.ts
Normal file
16
src/parser/classes/PageHeader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/PageHeaderView.ts
Normal file
17
src/parser/classes/PageHeaderView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { YTNode, type ObservedArray } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
import PlaylistCustomThumbnail from './PlaylistCustomThumbnail.js';
|
||||
import PlaylistVideoThumbnail from './PlaylistVideoThumbnail.js';
|
||||
import Author from './misc/Author.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Thumbnail from './misc/Thumbnail.js';
|
||||
@@ -12,6 +14,7 @@ export default class Playlist extends YTNode {
|
||||
title: Text;
|
||||
author: Text | Author;
|
||||
thumbnails: Thumbnail[];
|
||||
thumbnail_renderer?: PlaylistVideoThumbnail | PlaylistCustomThumbnail;
|
||||
video_count: Text;
|
||||
video_count_short: Text;
|
||||
first_videos: ObservedArray<YTNode>;
|
||||
@@ -41,6 +44,10 @@ export default class Playlist extends YTNode {
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
|
||||
|
||||
if (Reflect.has(data, 'thumbnailRenderer')) {
|
||||
this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, [ PlaylistVideoThumbnail, PlaylistCustomThumbnail ]) || undefined;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'viewPlaylistText')) {
|
||||
this.view_playlist = new Text(data.viewPlaylistText);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
25
src/parser/classes/Quiz.ts
Normal file
25
src/parser/classes/Quiz.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
import Text from './misc/Text.js';
|
||||
|
||||
export default class Quiz extends YTNode {
|
||||
static type = 'Quiz';
|
||||
|
||||
choices: {
|
||||
text: Text;
|
||||
is_correct: boolean;
|
||||
}[];
|
||||
|
||||
total_votes: Text;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
this.choices = data.choices.map((choice: RawNode) => ({
|
||||
text: new Text(choice.text),
|
||||
is_correct: choice.isCorrect
|
||||
}));
|
||||
|
||||
this.total_votes = new Text(data.totalVotes);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export default class ReelItem extends YTNode {
|
||||
thumbnails: Thumbnail[];
|
||||
views: Text;
|
||||
endpoint: NavigationEndpoint;
|
||||
accessibility_label?: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
@@ -20,5 +21,6 @@ export default class ReelItem extends YTNode {
|
||||
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
|
||||
this.views = new Text(data.viewCountText);
|
||||
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
|
||||
this.accessibility_label = data.accessibility.accessibilityData.label;
|
||||
}
|
||||
}
|
||||
18
src/parser/classes/SearchFilterOptionsDialog.ts
Normal file
18
src/parser/classes/SearchFilterOptionsDialog.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/SearchHeader.ts
Normal file
17
src/parser/classes/SearchHeader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
18
src/parser/classes/StructuredDescriptionContent.ts
Normal file
18
src/parser/classes/StructuredDescriptionContent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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';
|
||||
|
||||
export default class StructuredDescriptionContent extends YTNode {
|
||||
static type = 'StructuredDescriptionContent';
|
||||
|
||||
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection | HorizontalCardList>;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection, HorizontalCardList ]);
|
||||
}
|
||||
}
|
||||
28
src/parser/classes/VideoDescriptionHeader.ts
Normal file
28
src/parser/classes/VideoDescriptionHeader.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { YTNode, type ObservedArray } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import { Text, Thumbnail } from '../misc.js';
|
||||
import Factoid from './Factoid.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
|
||||
export default class VideoDescriptionHeader extends YTNode {
|
||||
static type = 'VideoDescriptionHeader';
|
||||
|
||||
channel: Text;
|
||||
channel_navigation_endpoint?: NavigationEndpoint;
|
||||
channel_thumbnail: Thumbnail[];
|
||||
factoids: ObservedArray<Factoid>;
|
||||
publish_date: Text;
|
||||
title: Text;
|
||||
views: Text;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.title = new Text(data.title);
|
||||
this.channel = new Text(data.channel);
|
||||
this.channel_navigation_endpoint = new NavigationEndpoint(data.channelNavigationEndpoint);
|
||||
this.channel_thumbnail = Thumbnail.fromResponse(data.channelThumbnail);
|
||||
this.publish_date = new Text(data.publishDate);
|
||||
this.views = new Text(data.views);
|
||||
this.factoids = Parser.parseArray(data.factoid, Factoid);
|
||||
}
|
||||
}
|
||||
28
src/parser/classes/VideoDescriptionInfocardsSection.ts
Normal file
28
src/parser/classes/VideoDescriptionInfocardsSection.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
|
||||
import { YTNode } from '../helpers.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Thumbnail from './misc/Thumbnail.js';
|
||||
import Button from './Button.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
|
||||
export default class VideoDescriptionInfocardsSection extends YTNode {
|
||||
static type = 'VideoDescriptionInfocardsSection';
|
||||
|
||||
section_title: Text;
|
||||
creator_videos_button: Button | null;
|
||||
creator_about_button: Button | null;
|
||||
section_subtitle: Text;
|
||||
channel_avatar: Thumbnail[];
|
||||
channel_endpoint: NavigationEndpoint;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.section_title = new Text(data.sectionTitle);
|
||||
this.creator_videos_button = Parser.parseItem(data.creatorVideosButton, Button);
|
||||
this.creator_about_button = Parser.parseItem(data.creatorAboutButton, Button);
|
||||
this.section_subtitle = new Text(data.sectionSubtitle);
|
||||
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
|
||||
this.channel_endpoint = new NavigationEndpoint(data.channelEndpoint);
|
||||
}
|
||||
}
|
||||
17
src/parser/classes/VideoDescriptionMusicSection.ts
Normal file
17
src/parser/classes/VideoDescriptionMusicSection.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { YTNode, type ObservedArray } from '../helpers.js';
|
||||
import Parser, { type RawNode } from '../index.js';
|
||||
import { Text } from '../misc.js';
|
||||
import CarouselLockup from './CarouselLockup.js';
|
||||
|
||||
export default class VideoDescriptionMusicSection extends YTNode {
|
||||
static type = 'VideoDescriptionMusicSection';
|
||||
|
||||
carousel_lockups: ObservedArray<CarouselLockup>;
|
||||
section_title: Text;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
this.carousel_lockups = Parser.parseArray(data.carouselLockups, CarouselLockup);
|
||||
this.section_title = new Text(data.sectionTitle);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
17
src/parser/classes/comments/CommentsSimplebox.ts
Normal file
17
src/parser/classes/comments/CommentsSimplebox.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ export default class Format {
|
||||
is_descriptive?: boolean;
|
||||
is_original?: boolean;
|
||||
|
||||
color_info?: {
|
||||
primaries?: string;
|
||||
transfer_characteristics?: string;
|
||||
matrix_coefficients?: string;
|
||||
};
|
||||
|
||||
constructor(data: RawNode) {
|
||||
this.itag = data.itag;
|
||||
this.mime_type = data.mimeType;
|
||||
@@ -81,14 +87,24 @@ export default class Format {
|
||||
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
|
||||
this.has_video = !!data.qualityLabel;
|
||||
|
||||
this.color_info = data.colorInfo ? {
|
||||
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
|
||||
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
|
||||
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
|
||||
} : undefined;
|
||||
|
||||
if (this.has_audio) {
|
||||
const args = new URLSearchParams(this.cipher || this.signature_cipher);
|
||||
const url_components = new URLSearchParams(args.get('url') || this.url);
|
||||
|
||||
this.language = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('lang='))?.split('=').at(1) || null;
|
||||
this.is_dubbed = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'dubbed';
|
||||
this.is_descriptive = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'descriptive';
|
||||
this.is_original = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'original' || !this.is_dubbed;
|
||||
const xtags = url_components.get('xtags')?.split(':');
|
||||
|
||||
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
|
||||
|
||||
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;
|
||||
|
||||
if (Reflect.has(data, 'audioTrack')) {
|
||||
this.audio_track = {
|
||||
|
||||
@@ -18,6 +18,7 @@ export default class VideoDetails {
|
||||
is_live_content: boolean;
|
||||
is_upcoming: boolean;
|
||||
is_crawlable: boolean;
|
||||
is_post_live_dvr: boolean;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
this.id = data.videoId;
|
||||
@@ -35,6 +36,7 @@ export default class VideoDetails {
|
||||
this.is_live = !!data.isLive;
|
||||
this.is_live_content = !!data.isLiveContent;
|
||||
this.is_upcoming = !!data.isUpcoming;
|
||||
this.is_post_live_dvr = !!data.isPostLiveDvr;
|
||||
this.is_crawlable = !!data.isCrawlable;
|
||||
}
|
||||
}
|
||||
211
src/parser/continuations.ts
Normal file
211
src/parser/continuations.ts
Normal 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
@@ -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;
|
||||
@@ -30,6 +30,7 @@ export { default as Card } from './classes/Card.js';
|
||||
export { default as CardCollection } from './classes/CardCollection.js';
|
||||
export { default as CarouselHeader } from './classes/CarouselHeader.js';
|
||||
export { default as CarouselItem } from './classes/CarouselItem.js';
|
||||
export { default as CarouselLockup } from './classes/CarouselLockup.js';
|
||||
export { default as Channel } from './classes/Channel.js';
|
||||
export { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullMetadata.js';
|
||||
export { default as ChannelAgeGate } from './classes/ChannelAgeGate.js';
|
||||
@@ -57,6 +58,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';
|
||||
@@ -69,6 +71,7 @@ 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';
|
||||
@@ -79,6 +82,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';
|
||||
@@ -88,9 +92,13 @@ export { default as Endscreen } from './classes/Endscreen.js';
|
||||
export { default as EndscreenElement } from './classes/EndscreenElement.js';
|
||||
export { default as EndScreenPlaylist } from './classes/EndScreenPlaylist.js';
|
||||
export { default as EndScreenVideo } from './classes/EndScreenVideo.js';
|
||||
export { default as EngagementPanelSectionList } from './classes/EngagementPanelSectionList.js';
|
||||
export { default as EngagementPanelTitleHeader } from './classes/EngagementPanelTitleHeader.js';
|
||||
export { default as ExpandableMetadata } from './classes/ExpandableMetadata.js';
|
||||
export { default as ExpandableTab } from './classes/ExpandableTab.js';
|
||||
export { default as ExpandableVideoDescriptionBody } from './classes/ExpandableVideoDescriptionBody.js';
|
||||
export { default as ExpandedShelfContents } from './classes/ExpandedShelfContents.js';
|
||||
export { default as Factoid } from './classes/Factoid.js';
|
||||
export { default as FeedFilterChipBar } from './classes/FeedFilterChipBar.js';
|
||||
export { default as FeedTabbedHeader } from './classes/FeedTabbedHeader.js';
|
||||
export { default as GameCard } from './classes/GameCard.js';
|
||||
@@ -110,6 +118,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';
|
||||
@@ -119,8 +128,10 @@ 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';
|
||||
export { default as InteractiveTabbedHeader } from './classes/InteractiveTabbedHeader.js';
|
||||
export { default as ItemSection } from './classes/ItemSection.js';
|
||||
export { default as ItemSectionHeader } from './classes/ItemSectionHeader.js';
|
||||
@@ -172,6 +183,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';
|
||||
@@ -210,6 +223,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';
|
||||
@@ -227,6 +241,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';
|
||||
@@ -259,6 +275,7 @@ export { default as ProfileColumn } from './classes/ProfileColumn.js';
|
||||
export { default as ProfileColumnStats } from './classes/ProfileColumnStats.js';
|
||||
export { default as ProfileColumnStatsEntry } from './classes/ProfileColumnStatsEntry.js';
|
||||
export { default as ProfileColumnUserInfo } from './classes/ProfileColumnUserInfo.js';
|
||||
export { default as Quiz } from './classes/Quiz.js';
|
||||
export { default as RecognitionShelf } from './classes/RecognitionShelf.js';
|
||||
export { default as ReelItem } from './classes/ReelItem.js';
|
||||
export { default as ReelShelf } from './classes/ReelShelf.js';
|
||||
@@ -273,6 +290,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';
|
||||
@@ -299,6 +318,7 @@ export { default as SingleHeroImage } from './classes/SingleHeroImage.js';
|
||||
export { default as SlimOwner } from './classes/SlimOwner.js';
|
||||
export { default as SlimVideoMetadata } from './classes/SlimVideoMetadata.js';
|
||||
export { default as SortFilterSubMenu } from './classes/SortFilterSubMenu.js';
|
||||
export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.js';
|
||||
export { default as SubFeedOption } from './classes/SubFeedOption.js';
|
||||
export { default as SubFeedSelector } from './classes/SubFeedSelector.js';
|
||||
export { default as SubscribeButton } from './classes/SubscribeButton.js';
|
||||
@@ -335,6 +355,9 @@ export { default as VerticalList } from './classes/VerticalList.js';
|
||||
export { default as VerticalWatchCardList } from './classes/VerticalWatchCardList.js';
|
||||
export { default as Video } from './classes/Video.js';
|
||||
export { default as VideoCard } from './classes/VideoCard.js';
|
||||
export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.js';
|
||||
export { default as VideoDescriptionInfocardsSection } from './classes/VideoDescriptionInfocardsSection.js';
|
||||
export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.js';
|
||||
export { default as VideoInfoCardContent } from './classes/VideoInfoCardContent.js';
|
||||
export { default as VideoOwner } from './classes/VideoOwner.js';
|
||||
export { default as VideoPrimaryInfo } from './classes/VideoPrimaryInfo.js';
|
||||
|
||||
1243
src/parser/parser.ts
1243
src/parser/parser.ts
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -17,7 +17,8 @@ import type VideoDetails from '../classes/misc/VideoDetails.js';
|
||||
import type Alert from '../classes/Alert.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;
|
||||
@@ -51,20 +52,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;
|
||||
@@ -73,29 +62,26 @@ export interface IParsedResponse {
|
||||
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
|
||||
endscreen?: Endscreen;
|
||||
cards?: CardCollection;
|
||||
engagement_panels?: EngagementPanelSectionList[];
|
||||
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;
|
||||
@@ -104,6 +90,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;
|
||||
@@ -111,6 +105,7 @@ export interface INextResponse {
|
||||
on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
|
||||
on_response_received_endpoints_memo?: Memo;
|
||||
player_overlays?: SuperParsedResult<YTNode>;
|
||||
engagement_panels?: EngagementPanelSectionList[];
|
||||
}
|
||||
|
||||
export interface IBrowseResponse {
|
||||
@@ -163,4 +158,4 @@ export interface IUpdatedMetadataResponse {
|
||||
export interface IGuideResponse {
|
||||
items: SuperParsedResult<YTNode>;
|
||||
items_memo: Memo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,6 @@ export interface IRawResponse {
|
||||
cards?: RawNode;
|
||||
items?: RawNode[];
|
||||
frameworkUpdates?: any;
|
||||
}
|
||||
engagementPanels: RawNode[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +165,16 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getReleases(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('releases');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getPodcasts(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('podcasts');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
}
|
||||
|
||||
async getPlaylists(): Promise<Channel> {
|
||||
const tab = await this.getTabByURL('playlists');
|
||||
return new Channel(this.actions, tab.page, true);
|
||||
@@ -217,6 +229,14 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
return this.hasTabWithURL('streams');
|
||||
}
|
||||
|
||||
get has_releases(): boolean {
|
||||
return this.hasTabWithURL('releases');
|
||||
}
|
||||
|
||||
get has_podcasts(): boolean {
|
||||
return this.hasTabWithURL('podcasts');
|
||||
}
|
||||
|
||||
get has_playlists(): boolean {
|
||||
return this.hasTabWithURL('playlists');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,7 +31,10 @@ class Search extends Feed<ISearchResponse> {
|
||||
if (!contents)
|
||||
throw new InnertubeError('No contents found in search response');
|
||||
|
||||
this.results = contents.filterType(ItemSection).find((section) => section.contents && section.contents.length > 0)?.contents;
|
||||
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 || [];
|
||||
this.estimated_results = this.page.estimated_results;
|
||||
|
||||
@@ -33,6 +33,8 @@ import type { ObservedArray, YTNode } from '../helpers.js';
|
||||
|
||||
import { InnertubeError } from '../../utils/Utils.js';
|
||||
import { MediaInfo } from '../../core/mixins/index.js';
|
||||
import StructuredDescriptionContent from '../classes/StructuredDescriptionContent.js';
|
||||
import { VideoDescriptionMusicSection } from '../nodes.js';
|
||||
|
||||
class VideoInfo extends MediaInfo {
|
||||
#watch_next_continuation?: ContinuationItem;
|
||||
@@ -97,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;
|
||||
@@ -336,41 +359,55 @@ class VideoInfo extends MediaInfo {
|
||||
/**
|
||||
* Get songs used in the video.
|
||||
*/
|
||||
// TODO: this seems to be broken with the new UI, further investigation needed
|
||||
get music_tracks() {
|
||||
/*
|
||||
Const metadata = this.secondary_info?.metadata;
|
||||
if (!metadata)
|
||||
return [];
|
||||
const songs = [];
|
||||
let current_song: Record<string, Text[]> = {};
|
||||
let is_music_section = false;
|
||||
for (let i = 0; i < metadata.rows.length; i++) {
|
||||
const row = metadata.rows[i];
|
||||
if (row.is(MetadataRowHeader)) {
|
||||
if (row.content?.toString().toLowerCase().startsWith('music')) {
|
||||
is_music_section = true;
|
||||
i++; // Skip the learn more link
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!is_music_section)
|
||||
continue;
|
||||
if (row.is(MetadataRow))
|
||||
current_song[row.title?.toString().toLowerCase().replace(/ /g, '_')] = row.contents;
|
||||
// TODO: this makes no sense, we continue above when
|
||||
if (row.has_divider_line) {
|
||||
songs.push(current_song);
|
||||
current_song = {};
|
||||
}
|
||||
// @TODO: Refactor this.
|
||||
const description_content = this.page[1]?.engagement_panels?.filter((panel) => panel.content?.is(StructuredDescriptionContent));
|
||||
if (description_content !== undefined && description_content.length > 0) {
|
||||
const music_section = description_content[0].content?.as(StructuredDescriptionContent)?.items?.filterType(VideoDescriptionMusicSection);
|
||||
if (music_section !== undefined && music_section.length > 0) {
|
||||
return music_section[0].carousel_lockups?.map((lookup) => {
|
||||
let song: string | undefined;
|
||||
let artist: string | undefined;
|
||||
let album: string | undefined;
|
||||
let license: string | undefined;
|
||||
let videoId: string | undefined;
|
||||
let channelId: string | undefined;
|
||||
|
||||
}
|
||||
if (is_music_section)
|
||||
songs.push(current_song);
|
||||
return songs;
|
||||
*/
|
||||
// If the song isn't in the video_lockup, it should be in the info_rows
|
||||
song = lookup.video_lockup?.title?.toString();
|
||||
// If the video id isn't in the video_lockup, it should be in the info_rows
|
||||
videoId = lookup.video_lockup?.endpoint.payload.videoId;
|
||||
for (let i = 0; i < lookup.info_rows.length; i++) {
|
||||
const info_row = lookup.info_rows[i];
|
||||
if (info_row.info_row_expand_status_key === undefined) {
|
||||
if (song === undefined) {
|
||||
song = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
|
||||
if (videoId === undefined) {
|
||||
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
|
||||
videoId = endpoint?.payload?.videoId;
|
||||
}
|
||||
} else {
|
||||
album = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
|
||||
}
|
||||
} else {
|
||||
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-artists-row-state-id') !== -1) {
|
||||
artist = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
|
||||
if (channelId === undefined) {
|
||||
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
|
||||
channelId = endpoint?.payload?.browseId;
|
||||
}
|
||||
}
|
||||
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-licenses-row-state-id') !== -1) {
|
||||
license = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return { song, artist, album, license, videoId, channelId };
|
||||
});
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default VideoInfo;
|
||||
export default VideoInfo;
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ApiResponse } from '../../core/Actions.js';
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
import type { IBrowseResponse } from '../types/ParsedResponse.js';
|
||||
import { InnertubeError } from '../../utils/Utils.js';
|
||||
import ChipCloud from '../classes/ChipCloud.js';
|
||||
import ChipCloudChip from '../classes/ChipCloudChip.js';
|
||||
|
||||
class HomeFeed {
|
||||
#page: IBrowseResponse;
|
||||
@@ -16,6 +18,7 @@ class HomeFeed {
|
||||
#continuation?: string;
|
||||
|
||||
sections?: ObservedArray<MusicCarouselShelf | MusicTastebuilderShelf>;
|
||||
header?: ChipCloud;
|
||||
|
||||
constructor(response: ApiResponse, actions: Actions) {
|
||||
this.#actions = actions;
|
||||
@@ -26,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.');
|
||||
|
||||
@@ -36,6 +39,7 @@ class HomeFeed {
|
||||
return;
|
||||
}
|
||||
|
||||
this.header = tab.content?.as(SectionList).header?.as(ChipCloud);
|
||||
this.#continuation = tab.content?.as(SectionList).continuation;
|
||||
this.sections = tab.content?.as(SectionList).contents.as(MusicCarouselShelf, MusicTastebuilderShelf);
|
||||
}
|
||||
@@ -55,6 +59,33 @@ class HomeFeed {
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
async applyFilter(target_filter: string | ChipCloudChip): Promise<HomeFeed> {
|
||||
let cloud_chip: ChipCloudChip | undefined;
|
||||
|
||||
if (typeof target_filter === 'string') {
|
||||
cloud_chip = this.header?.chips?.as(ChipCloudChip).get({ text: target_filter });
|
||||
if (!cloud_chip)
|
||||
throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters });
|
||||
} else if (target_filter?.is(ChipCloudChip)) {
|
||||
cloud_chip = target_filter;
|
||||
}
|
||||
|
||||
if (!cloud_chip)
|
||||
throw new InnertubeError('Invalid filter', { available_filters: this.filters });
|
||||
|
||||
if (cloud_chip?.is_selected) return this;
|
||||
|
||||
if (!cloud_chip.endpoint)
|
||||
throw new InnertubeError('Selected filter does not have an endpoint.');
|
||||
|
||||
const response = await cloud_chip.endpoint.call(this.#actions, { client: 'YTMUSIC' });
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
|
||||
get filters(): string[] {
|
||||
return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || [];
|
||||
}
|
||||
|
||||
get has_continuation(): boolean {
|
||||
return !!this.#continuation;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { DOMParser as DOMParserImpl } from 'linkedom';
|
||||
|
||||
export default DOMParserImpl as typeof globalThis.DOMParser;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
9
src/types/DashOptions.ts
Normal 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
37
src/types/FormatUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -29,13 +29,19 @@ export const OAUTH = Object.freeze({
|
||||
}),
|
||||
REGEX: Object.freeze({
|
||||
AUTH_SCRIPT: /<script id="base-js" src="(.*?)" nonce=".*?"><\/script>/,
|
||||
CLIENT_IDENTITY: /.+?={};var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)"},/
|
||||
CLIENT_IDENTITY: /var .+?={clientId:"(?<client_id>.+?)",.+?:"(?<client_secret>.+?)".+?}/
|
||||
})
|
||||
});
|
||||
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.20230104.01.00',
|
||||
VERSION: '2.20230622.06.00',
|
||||
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
|
||||
API_VERSION: 'v1',
|
||||
STATIC_VISITOR_ID: '6zpwvWUNAco'
|
||||
|
||||
249
src/utils/DashManifest.tsx
Normal file
249
src/utils/DashManifest.tsx
Normal 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={`audio_${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={`audio_${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={`video_${index}`}
|
||||
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={`thumbs_${index}`}
|
||||
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
104
src/utils/DashUtils.ts
Normal 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 = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
} 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;
|
||||
}
|
||||
@@ -2,570 +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 filtered_streaming_data;
|
||||
|
||||
if (format_filter) {
|
||||
filtered_streaming_data = {
|
||||
formats: streaming_data.formats.filter((fmt: Format) => !(format_filter(fmt))),
|
||||
adaptive_formats: streaming_data.adaptive_formats.filter((fmt: Format) => !(format_filter(fmt))),
|
||||
expires: streaming_data.expires,
|
||||
dash_manifest_url: streaming_data.dash_manifest_url,
|
||||
hls_manifest_url: streaming_data.hls_manifest_url
|
||||
};
|
||||
} else {
|
||||
filtered_streaming_data = streaming_data;
|
||||
}
|
||||
|
||||
const { adaptive_formats } = filtered_streaming_data;
|
||||
|
||||
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
|
||||
})
|
||||
);
|
||||
|
||||
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
|
||||
}, children);
|
||||
|
||||
period.appendChild(set);
|
||||
|
||||
for (const format of track_objects[j]) {
|
||||
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const set = this.#el(document, 'AdaptationSet', {
|
||||
id: `${set_id++}`,
|
||||
mimeType: mime_types[i].split(';')[0],
|
||||
startWithSAP: '1',
|
||||
subsegmentAlignment: 'true'
|
||||
});
|
||||
|
||||
period.appendChild(set);
|
||||
|
||||
for (const format of mime_objects[i]) {
|
||||
if (format.has_video) {
|
||||
await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions);
|
||||
} else {
|
||||
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
|
||||
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
|
||||
|
||||
const url = new URL(format.decipher(player));
|
||||
url.searchParams.set('cpn', cpn || '');
|
||||
|
||||
const representation = this.#el(document, 'Representation', {
|
||||
id: format.itag?.toString(),
|
||||
codecs,
|
||||
bandwidth: format.bitrate?.toString(),
|
||||
width: format.width?.toString(),
|
||||
height: format.height?.toString(),
|
||||
maxPlayoutRate: '1',
|
||||
frameRate: format.fps?.toString()
|
||||
});
|
||||
|
||||
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, cpn?: string, player?: Player, actions?: Actions) {
|
||||
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
|
||||
|
||||
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,
|
||||
codecs,
|
||||
bandwidth: format.bitrate?.toString(),
|
||||
audioSamplingRate: format.audio_sample_rate?.toString()
|
||||
}, [
|
||||
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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user