Compare commits

...

33 Commits

Author SHA1 Message Date
github-actions[bot]
cd69ce73c1 chore(main): release 9.3.0 (#635)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-11 18:19:12 -03:00
LuanRT
1c08bfe113 feat(CommentView): Implement comment interaction methods 2024-04-11 18:04:45 -03:00
LuanRT
a624963384 docs(Comments): Update API ref 2024-04-11 18:03:04 -03:00
LuanRT
66e34f9388 fix(CommentThread): Replies not being parsed correctly 2024-04-11 16:05:59 -03:00
github-actions[bot]
0c2cdc1599 chore(main): release 9.2.1 (#632)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-09 17:42:18 -03:00
absidue
010704929f fix(toDash): Add missing transfer characteristics for h264 streams (#631) 2024-04-09 17:41:08 -03:00
dependabot[bot]
d4a938771b chore(deps): bump undici from 5.28.3 to 5.28.4 (#627)
Bumps [undici](https://github.com/nodejs/undici) from 5.28.3 to 5.28.4.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v5.28.3...v5.28.4)

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

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

* refactor: Update shorts parameter protobuf

* chore: Update auto generated files

* chore: Add test

* chore: Update comments test id (unrelated)

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

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

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

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

Closes #602, #603, #604
2024-02-22 22:25:30 -03:00
LuanRT
3e84775fd3 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-02-18 23:37:13 -03:00
LuanRT
89fa3b27a8 fix(Playlist): items getter failing if a playlist contains Shorts 2024-02-18 23:36:01 -03:00
LuanRT
0751793380 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2024-01-29 22:19:35 -03:00
LuanRT
5c91c2af4a chore: merge main 2024-01-29 22:14:22 -03:00
LuanRT
47cad4c6e1 chore: lint build scripts [skip ci] 2024-01-29 22:11:20 -03:00
53 changed files with 1301 additions and 202 deletions

View File

@@ -14,6 +14,7 @@ overrides:
-
files:
- '**/*.js'
- '**/*.mjs'
rules:
'tsdoc/syntax': 'off'
rules:

View File

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

View File

@@ -13,5 +13,6 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. Remove the stale label or comment or this will be closed in 2 days'
any-of-issue-labels: 'needs-more-info,cannot-reproduce,question,help-wanted'
days-before-stale: 60
days-before-close: 4

View File

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

View File

@@ -1,5 +1,53 @@
# Changelog
## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)
### Features
* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))
### Bug Fixes
* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))
## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)
### Bug Fixes
* **toDash:** Add missing transfer characteristics for h264 streams ([#631](https://github.com/LuanRT/YouTube.js/issues/631)) ([0107049](https://github.com/LuanRT/YouTube.js/commit/010704929fa4b737f68a5a1f10bf0b166cfbf905))
## [9.2.0](https://github.com/LuanRT/YouTube.js/compare/v9.1.0...v9.2.0) (2024-03-31)
### Features
* add support of cloudflare workers ([#596](https://github.com/LuanRT/YouTube.js/issues/596)) ([2029aec](https://github.com/LuanRT/YouTube.js/commit/2029aec90de3c0fdb022094d7b704a2feed4133b))
* **parser:** Support CommentView nodes ([#614](https://github.com/LuanRT/YouTube.js/issues/614)) ([900f557](https://github.com/LuanRT/YouTube.js/commit/900f5572021d348e7012909f2080e52eac06adae))
* **parser:** Support LockupView and it's child nodes ([#609](https://github.com/LuanRT/YouTube.js/issues/609)) ([7ca2a0c](https://github.com/LuanRT/YouTube.js/commit/7ca2a0c3e43ebd4b9443e69b7432f302b09e9c7a))
* **Text:** Support formatting and emojis in `fromAttributed` ([#615](https://github.com/LuanRT/YouTube.js/issues/615)) ([e6f1f07](https://github.com/LuanRT/YouTube.js/commit/e6f1f078a828f8ea5db1fe7aec9f677bc53694e3))
### Bug Fixes
* **Cache:** handle the value read from the db correctly according to its type ([#620](https://github.com/LuanRT/YouTube.js/issues/620)) ([3170659](https://github.com/LuanRT/YouTube.js/commit/317065988007c860bf6173b0ac3c3d685ed81d20))
* **PlayerEndpoint:** Workaround for "The following content is not available on this app" (Android) ([#624](https://github.com/LuanRT/YouTube.js/issues/624)) ([d589365](https://github.com/LuanRT/YouTube.js/commit/d589365ea27f540ff36e33a65362c932cd28c274))
## [9.1.0](https://github.com/LuanRT/YouTube.js/compare/v9.0.2...v9.1.0) (2024-02-23)
### Features
* **Format:** Support caption tracks in adaptive formats ([#598](https://github.com/LuanRT/YouTube.js/issues/598)) ([bff65f8](https://github.com/LuanRT/YouTube.js/commit/bff65f8889c32813ec05bd187f3a4386fc6127c0))
### Bug Fixes
* **Playlist:** `items` getter failing if a playlist contains Shorts ([89fa3b2](https://github.com/LuanRT/YouTube.js/commit/89fa3b27a839d98aaf8bd70dd75220ee309c2bea))
* **Session:** Don't try to extract api version from service worker ([2068dfb](https://github.com/LuanRT/YouTube.js/commit/2068dfb73eefc0e40157421d4e5b4096c0c8429c))
## [9.0.2](https://github.com/LuanRT/YouTube.js/compare/v9.0.1...v9.0.2) (2024-01-31)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "9.0.2",
"version": "9.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "9.0.2",
"version": "9.3.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],
@@ -5498,8 +5498,9 @@
}
},
"node_modules/undici": {
"version": "5.27.0",
"license": "MIT",
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
@@ -9138,7 +9139,9 @@
"dev": true
},
"undici": {
"version": "5.27.0",
"version": "5.28.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
"requires": {
"@fastify/busboy": "^2.0.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "9.0.2",
"version": "9.3.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -17,6 +17,9 @@
],
"web.bundle.min": [
"./dist/src/platform/lib.d.ts"
],
"cf-worker": [
"./dist/src/platform/lib.d.ts"
]
}
},
@@ -46,6 +49,10 @@
"./web.bundle.min": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./bundle/browser.min.js"
},
"./cf-worker": {
"types": "./dist/src/platform/lib.d.ts",
"default": "./dist/src/platform/cf-worker.js"
}
},
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
@@ -68,7 +75,7 @@
"test": "npx jest --verbose",
"lint": "npx eslint ./src",
"lint:fix": "npx eslint --fix ./src",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod",
"build": "npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker",
"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 tspc",
@@ -76,6 +83,7 @@
"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",
"bundle:cf-worker": "npx esbuild ./dist/src/platform/cf-worker.js --banner:js=\"/* eslint-disable */\" --bundle --target=es2020 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/cf-worker.js --platform=node",
"prepare": "npm run build",
"watch": "npx tsc --watch"
},

View File

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

View File

@@ -33,25 +33,25 @@ export interface Context {
hl: string;
gl: string;
remoteHost?: string;
screenDensityFloat: number;
screenHeightPoints: number;
screenPixelDensity: number;
screenWidthPoints: number;
visitorData: string;
screenDensityFloat?: number;
screenHeightPoints?: number;
screenPixelDensity?: number;
screenWidthPoints?: number;
visitorData?: string;
clientName: string;
clientVersion: string;
clientScreen?: string,
androidSdkVersion?: string;
androidSdkVersion?: number;
osName: string;
osVersion: string;
platform: string;
clientFormFactor: string;
userInterfaceTheme: string;
userInterfaceTheme?: string;
timeZone: string;
userAgent?: string;
browserName?: string;
browserVersion?: string;
originalUrl: string;
originalUrl?: string;
deviceMake: string;
deviceModel: string;
utcOffsetMinutes: number;
@@ -73,6 +73,10 @@ export interface Context {
thirdParty?: {
embedUrl: string;
};
request?: {
useSsl: boolean;
internalExperimentFlags: any[];
};
}
export interface SessionOptions {
@@ -296,7 +300,7 @@ export default class Session extends EventEmitter {
const ytcfg = data[0][2];
const api_version = `v${ytcfg[0][0][6]}`;
const api_version = Constants.CLIENTS.WEB.API_VERSION;
const [ [ device_info ], api_key ] = ytcfg;
@@ -327,11 +331,17 @@ export default class Session extends EventEmitter {
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
lockedSafetyMode: false
},
request: {
useSsl: true,
internalExperimentFlags: []
}
};
if (options.on_behalf_of_user)
context.user.onBehalfOfUser = options.on_behalf_of_user;
return { context, api_key, api_version };
}
@@ -366,11 +376,17 @@ export default class Session extends EventEmitter {
},
user: {
enableSafetyMode: options.enable_safety_mode,
lockedSafetyMode: false,
onBehalfOfUser: options.on_behalf_of_user
lockedSafetyMode: false
},
request: {
useSsl: true,
internalExperimentFlags: []
}
};
if (options.on_behalf_of_user)
context.user.onBehalfOfUser = options.on_behalf_of_user;
return { context, api_key: Constants.CLIENTS.WEB.API_KEY, api_version: Constants.CLIENTS.WEB.API_VERSION };
}

View File

@@ -1,3 +1,4 @@
import { encodeShortsParam } from '../../proto/index.js';
import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js';
export const PATH = '/player';
@@ -43,7 +44,7 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
client: opts.client,
playlistId: opts.playlist_id,
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
params: is_android ? '2AMBCgIQBg' : opts.params
params: is_android ? encodeShortsParam() : opts.params
}
};
}

View File

@@ -8,6 +8,7 @@ import CompactVideo from '../../parser/classes/CompactVideo.js';
import GridChannel from '../../parser/classes/GridChannel.js';
import GridPlaylist from '../../parser/classes/GridPlaylist.js';
import GridVideo from '../../parser/classes/GridVideo.js';
import LockupView from '../../parser/classes/LockupView.js';
import Playlist from '../../parser/classes/Playlist.js';
import PlaylistPanelVideo from '../../parser/classes/PlaylistPanelVideo.js';
import PlaylistVideo from '../../parser/classes/PlaylistVideo.js';
@@ -88,7 +89,18 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
* Get all playlists on a given page via memo
*/
static getPlaylistsFromMemo(memo: Memo) {
return memo.getType(Playlist, GridPlaylist);
const playlists: ObservedArray<Playlist | GridPlaylist | LockupView> = memo.getType(Playlist, GridPlaylist);
const lockup_views = memo.getType(LockupView)
.filter((lockup) => {
return [ 'PLAYLIST', 'ALBUM', 'PODCAST' ].includes(lockup.content_type);
});
if (lockup_views.length > 0) {
playlists.push(...lockup_views);
}
return playlists;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailHoverOverlayView from './ThumbnailHoverOverlayView.js';
import ThumbnailOverlayBadgeView from './ThumbnailOverlayBadgeView.js';
import Thumbnail from './misc/Thumbnail.js';
export default class ThumbnailView extends YTNode {
static type = 'ThumbnailView';
image: Thumbnail[];
overlays: (ThumbnailOverlayBadgeView | ThumbnailHoverOverlayView)[];
background_color: {
light_theme: number;
dark_theme: number;
};
constructor(data: RawNode) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.overlays = Parser.parseArray(data.overlays, [ ThumbnailOverlayBadgeView, ThumbnailHoverOverlayView ]);
this.background_color = {
light_theme: data.backgroundColor.lightTheme,
dark_theme: data.backgroundColor.darkTheme
};
}
}

View File

@@ -2,6 +2,7 @@ import { Parser } from '../../index.js';
import Button from '../Button.js';
import ContinuationItem from '../ContinuationItem.js';
import Comment from './Comment.js';
import CommentView from './CommentView.js';
import CommentReplies from './CommentReplies.js';
import { InnertubeError } from '../../../utils/Utils.js';
@@ -17,15 +18,20 @@ export default class CommentThread extends YTNode {
#actions?: Actions;
#continuation?: ContinuationItem;
comment: Comment | null;
replies?: ObservedArray<Comment>;
comment: Comment | CommentView | null;
replies?: ObservedArray<Comment | CommentView>;
comment_replies_data: CommentReplies | null;
is_moderated_elq_comment: boolean;
has_replies: boolean;
constructor(data: RawNode) {
super();
this.comment = Parser.parseItem(data.comment, Comment);
if (Reflect.has(data, 'commentViewModel')) {
this.comment = Parser.parseItem(data.commentViewModel, CommentView);
} else {
this.comment = Parser.parseItem(data.comment, Comment);
}
this.comment_replies_data = Parser.parseItem(data.replies, CommentReplies);
this.is_moderated_elq_comment = data.isModeratedElqComment;
this.has_replies = !!this.comment_replies_data;
@@ -51,7 +57,7 @@ export default class CommentThread extends YTNode {
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));
@@ -84,7 +90,7 @@ export default class CommentThread extends YTNode {
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));

View File

@@ -0,0 +1,241 @@
import { YTNode } from '../../helpers.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import Author from '../misc/Author.js';
import Text from '../misc/Text.js';
import CommentReplyDialog from './CommentReplyDialog.js';
import { InnertubeError } from '../../../utils/Utils.js';
import * as Proto from '../../../proto/index.js';
import type Actions from '../../../core/Actions.js';
import type { ApiResponse } from '../../../core/Actions.js';
import type { RawNode } from '../../index.js';
export default class CommentView extends YTNode {
static type = 'CommentView';
#actions?: Actions;
like_command?: NavigationEndpoint;
dislike_command?: NavigationEndpoint;
unlike_command?: NavigationEndpoint;
undislike_command?: NavigationEndpoint;
reply_command?: NavigationEndpoint;
comment_id: string;
is_pinned: boolean;
keys: {
comment: string;
comment_surface: string;
toolbar_state: string;
toolbar_surface: string;
shared: string;
};
content?: Text;
published_time?: string;
author_is_channel_owner?: boolean;
like_count?: string;
reply_count?: string;
is_member?: boolean;
member_badge?: {
url: string,
a11y: string;
};
author?: Author;
test: any;
is_liked?: boolean;
is_disliked?: boolean;
is_hearted?: boolean;
constructor(data: RawNode) {
super();
this.comment_id = data.commentId;
this.is_pinned = !!data.pinnedText;
this.keys = {
comment: data.commentKey,
comment_surface: data.commentSurfaceKey,
toolbar_state: data.toolbarStateKey,
toolbar_surface: data.toolbarSurfaceKey,
shared: data.sharedKey
};
}
applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) {
if (comment) {
this.content = Text.fromAttributed(comment.properties.content);
this.published_time = comment.properties.publishedTime;
this.author_is_channel_owner = !!comment.author.isCreator;
this.like_count = comment.toolbar.likeCountNotliked ? comment.toolbar.likeCountNotliked : '0';
this.reply_count = comment.toolbar.replyCount ? comment.toolbar.replyCount : '0';
this.is_member = !!comment.author.sponsorBadgeUrl;
if (Reflect.has(comment.author, 'sponsorBadgeUrl')) {
this.member_badge = {
url: comment.author.sponsorBadgeUrl,
a11y: comment.author.A11y
};
}
this.author = new Author({
simpleText: comment.author.displayName,
navigationEndpoint: comment.avatar.endpoint
}, comment.author, comment.avatar.image, comment.author.channelId);
}
if (toolbar_state) {
this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED';
this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED';
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
}
if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
}
}
/**
* Likes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the like command is not found.
*/
async like(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.like_command)
throw new InnertubeError('Like command not found.');
if (this.is_liked)
throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id });
return this.like_command.call(this.#actions);
}
/**
* Dislikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the dislike command is not found.
*/
async dislike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.dislike_command)
throw new InnertubeError('Dislike command not found.');
if (this.is_disliked)
throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id });
return this.dislike_command.call(this.#actions);
}
/**
* Unlikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the unlike command is not found.
*/
async unlike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.unlike_command)
throw new InnertubeError('Unlike command not found.');
if (!this.is_liked)
throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id });
return this.unlike_command.call(this.#actions);
}
/**
* Undislikes the comment.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the undislike command is not found.
*/
async undislike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.undislike_command)
throw new InnertubeError('Undislike command not found.');
if (!this.is_disliked)
throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id });
return this.undislike_command.call(this.#actions);
}
/**
* Replies to the comment.
* @param comment_text - The text of the reply.
* @returns A promise that resolves to the API response.
* @throws If the Actions instance is not set for this comment or if the reply command is not found.
*/
async reply(comment_text: string): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.reply_command)
throw new InnertubeError('Reply command not found.');
const dialog = this.reply_command.dialog?.as(CommentReplyDialog);
if (!dialog)
throw new InnertubeError('Reply dialog not found.');
const reply_button = dialog.reply_button;
if (!reply_button)
throw new InnertubeError('Reply button not found in the dialog.');
if (!reply_button.endpoint)
throw new InnertubeError('Reply button endpoint not found.');
return reply_button.endpoint.call(this.#actions, { commentText: comment_text });
}
/**
* Translates the comment to the specified target language.
* @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'.
* @returns A Promise that resolves to an ApiResponse object with the translated content, if available.
* @throws if the Actions instance is not set for this comment or if the comment content is not found.
*/
async translate(target_language: string): Promise<ApiResponse & { content?: string }> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment.');
if (!this.content)
throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id });
// Emojis must be removed otherwise InnerTube throws a 400 status code at us.
const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');
const payload = {
text,
target_language
};
const action = Proto.encodeCommentActionParams(22, payload);
const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });
// XXX: Should move this to Parser#parseResponse
const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;
return { ...response, content };
}
setActions(actions: Actions | undefined) {
this.#actions = actions;
}
}

View File

@@ -25,10 +25,21 @@ export default class Author {
this.name = nav_text?.text || 'N/A';
this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : [];
this.endpoint = ((nav_text?.runs?.[0] as TextRun) as TextRun)?.endpoint || nav_text?.endpoint;
this.badges = Array.isArray(badges) ? Parser.parseArray(badges) : observe([] as YTNode[]);
this.is_moderator = this.badges?.some((badge: any) => badge.icon_type == 'MODERATOR');
this.is_verified = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED');
this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST');
if (badges) {
if (Array.isArray(badges)) {
this.badges = Parser.parseArray(badges);
this.is_moderator = this.badges?.some((badge: any) => badge.icon_type == 'MODERATOR');
this.is_verified = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED');
this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST');
} else {
this.badges = observe([] as YTNode[]);
this.is_verified = !!badges.isVerified;
this.is_verified_artist = !!badges.isArtist;
}
} else {
this.badges = observe([] as YTNode[]);
}
// @TODO: Refactor this mess.
this.url =

View File

@@ -16,6 +16,7 @@ export default class EmojiRun implements Run {
this.text =
data.emoji?.emojiId ||
data.emoji?.shortcuts?.[0] ||
data.text ||
'';
this.emoji = {

View File

@@ -45,6 +45,7 @@ export default class Format {
target_duration_dec?: number;
has_audio: boolean;
has_video: boolean;
has_text: boolean;
language?: string | null;
is_dubbed?: boolean;
is_descriptive?: boolean;
@@ -56,6 +57,14 @@ export default class Format {
matrix_coefficients?: string;
};
caption_track?: {
display_name: string;
vss_id: string;
language_code: string;
kind?: 'asr' | 'frc';
id: string;
};
constructor(data: RawNode, this_response_nsig_cache?: Map<string, string>) {
if (this_response_nsig_cache) {
this.#this_response_nsig_cache = this_response_nsig_cache;
@@ -96,6 +105,7 @@ export default class Format {
this.target_duration_dec = data.targetDurationSec;
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
this.has_video = !!data.qualityLabel;
this.has_text = !!data.captionTrack;
this.color_info = data.colorInfo ? {
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
@@ -103,25 +113,42 @@ export default class Format {
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
} : undefined;
if (this.has_audio) {
if (Reflect.has(data, 'audioTrack')) {
this.audio_track = {
audio_is_default: data.audioTrack.audioIsDefault,
display_name: data.audioTrack.displayName,
id: data.audioTrack.id
};
}
if (Reflect.has(data, 'captionTrack')) {
this.caption_track = {
display_name: data.captionTrack.displayName,
vss_id: data.captionTrack.vssId,
language_code: data.captionTrack.languageCode,
kind: data.captionTrack.kind,
id: data.captionTrack.id
};
}
if (this.has_audio || this.has_text) {
const args = new URLSearchParams(this.cipher || this.signature_cipher);
const url_components = new URLSearchParams(args.get('url') || this.url);
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 = {
audio_is_default: data.audioTrack.audioIsDefault,
display_name: data.audioTrack.displayName,
id: data.audioTrack.id
};
if (this.has_audio) {
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
this.is_dubbed = audio_content === 'dubbed';
this.is_descriptive = audio_content === 'descriptive';
this.is_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive);
}
// Some text tracks don't have xtags while others do
if (this.has_text && !this.language && this.caption_track) {
this.language = this.caption_track.language_code;
}
}
}

View File

@@ -1,3 +1,4 @@
import { Log } from '../../../utils/index.js';
import type { RawNode } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import EmojiRun from './EmojiRun.js';
@@ -18,6 +19,10 @@ export function escape(text: string) {
.replace(/'/g, '&#039;');
}
// Place this here, instead of in a private static property,
// To avoid the performance penalty of the private field polyfill
const TAG = 'Text';
export default class Text {
text?: string;
runs?: (EmojiRun | TextRun)[];
@@ -46,73 +51,132 @@ export default class Text {
}
}
static fromAttributed(data: RawNode): Text {
const runs: {
text: string,
navigationEndpoint?: RawNode,
attachment?: RawNode
}[] = [];
static fromAttributed(data: AttributedText) {
const {
content,
styleRuns: style_runs,
commandRuns: command_runs,
attachmentRuns: attachment_runs
} = data;
const content = data.content;
const command_runs = data.commandRuns;
const runs: RawRun[] = [
{
text: content,
startIndex: 0
}
];
// Haven't found an actually useful one yet, but they look like this:
// [ { startIndex: 0, length: 19 } ] (for a string that is 19 characters long)
// Const style_runs = data.styleRuns;
if (style_runs || command_runs || attachment_runs) {
if (style_runs) {
for (const style_run of style_runs) {
if (
style_run.italic ||
style_run.strikethrough === 'LINE_STYLE_SINGLE' ||
style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' ||
style_run.weightLabel === 'FONT_WEIGHT_BOLD'
) {
const matching_run = findMatchingRun(runs, style_run);
let last_end_index = 0;
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', {
style_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
if (command_runs) {
for (const item of command_runs) {
const length: number = item.length;
const start_index: number = item.startIndex;
if (start_index > last_end_index) {
runs.push({
text: content.slice(last_end_index, start_index)
});
}
if (Reflect.has(item, 'onTap')) {
let attachment = null;
if (Reflect.has(data, 'attachmentRuns')) {
const attachment_runs = data.attachmentRuns;
for (const attatchment_run of attachment_runs) {
if ((attatchment_run.startIndex - 2) == start_index) {
attachment = attatchment_run;
break;
}
continue;
}
}
if (attachment) {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap,
attachment
// Comments use MEDIUM for bold text and video descriptions use BOLD for bold text
insertSubRun(runs, matching_run, style_run, {
bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD',
italics: style_run.italic,
strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE'
});
} else {
runs.push({
text: content.slice(start_index, start_index + length),
navigationEndpoint: item.onTap
Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', {
style_run,
input_data: data
});
}
}
last_end_index = start_index + length;
}
if (last_end_index < content.length) {
runs.push({
text: content.slice(last_end_index)
});
if (command_runs) {
for (const command_run of command_runs) {
if (command_run.onTap) {
const matching_run = findMatchingRun(runs, command_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', {
command_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
insertSubRun(runs, matching_run, command_run, {
navigationEndpoint: command_run.onTap
});
} else {
Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', {
command_run,
input_data: data
});
}
}
}
if (attachment_runs) {
for (const attachment_run of attachment_runs) {
const matching_run = findMatchingRun(runs, attachment_run);
if (!matching_run) {
Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', {
attachment_run,
input_data: data,
// For performance reasons, web browser consoles only expand an object, when the user clicks on it,
// So if we log the original runs object, it might have changed by the time the user looks at it.
// Deep clone, so that we log the exact state of the runs at this point.
parsed_runs: JSON.parse(JSON.stringify(runs))
});
continue;
}
if (attachment_run.length === 0) {
matching_run.attachment = attachment_run;
} else {
const offset_start_index = attachment_run.startIndex - matching_run.startIndex;
const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length);
const is_custom_emoji = (/^:[^:]+:$/).test(text);
if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) {
const emoji = {
image: attachment_run.element.type.imageType.image,
isCustomEmoji: is_custom_emoji,
shortcuts: is_custom_emoji ? [ text ] : undefined
};
insertSubRun(runs, matching_run, attachment_run, { emoji });
} else {
insertSubRun(runs, matching_run, attachment_run, {
attachment: attachment_run
});
}
}
}
}
} else {
runs.push({
text: content
});
}
return new Text({ runs });
@@ -141,4 +205,100 @@ export default class Text {
toString(): string {
return this.text || 'N/A';
}
}
function findMatchingRun(runs: RawRun[], response_run: ResponseRun) {
return runs.find((run) => {
return run.startIndex <= response_run.startIndex &&
response_run.startIndex + response_run.length <= run.startIndex + run.text.length;
});
}
function insertSubRun(runs: RawRun[], original_run: RawRun, response_run: ResponseRun, properties_to_add: Omit<RawRun, 'text' | 'startIndex'>) {
const replace_index = runs.indexOf(original_run);
const replacement_runs = [];
const offset_start_index = response_run.startIndex - original_run.startIndex;
// Stuff before the run
if (response_run.startIndex > original_run.startIndex) {
replacement_runs.push({
...original_run,
text: original_run.text.substring(0, offset_start_index)
});
}
replacement_runs.push({
...original_run,
text: original_run.text.substring(offset_start_index, offset_start_index + response_run.length),
startIndex: response_run.startIndex,
...properties_to_add
});
// Stuff after the run
if (response_run.startIndex + response_run.length < original_run.startIndex + original_run.text.length) {
replacement_runs.push({
...original_run,
text: original_run.text.substring(offset_start_index + response_run.length),
startIndex: response_run.startIndex + response_run.length
});
}
runs.splice(replace_index, 1, ...replacement_runs);
}
interface RawRun {
text: string,
bold?: boolean;
italics?: boolean;
strikethrough?: boolean;
navigationEndpoint?: RawNode;
attachment?: RawNode;
emoji?: RawNode;
startIndex: number;
}
interface AttributedText {
content: string;
styleRuns?: StyleRun[];
commandRuns?: CommandRun[];
attachmentRuns?: AttachmentRun[];
decorationRuns?: ResponseRun[];
}
interface ResponseRun {
startIndex: number;
length: number;
}
interface StyleRun extends ResponseRun {
italic?: boolean;
weightLabel?: string;
strikethrough?: string;
fontFamilyName?: string;
styleRunExtensions?: {
styleRunColorMapExtension?: {
colorMap?: {
key: string,
value: number
}[]
}
}
}
interface CommandRun extends ResponseRun {
onTap?: RawNode;
}
interface AttachmentRun extends ResponseRun {
alignment?: string;
element?: {
type?: {
imageType?: {
image: RawNode,
playbackState?: string;
}
};
properties?: RawNode
};
}

View File

@@ -64,6 +64,7 @@ export { default as ClipCreationTextInput } from './classes/ClipCreationTextInpu
export { default as ClipSection } from './classes/ClipSection.js';
export { default as CollaboratorInfoCardContent } from './classes/CollaboratorInfoCardContent.js';
export { default as CollageHeroImage } from './classes/CollageHeroImage.js';
export { default as CollectionThumbnailView } from './classes/CollectionThumbnailView.js';
export { default as Command } from './classes/Command.js';
export { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge.js';
export { default as Comment } from './classes/comments/Comment.js';
@@ -77,6 +78,7 @@ 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 CommentView } from './classes/comments/CommentView.js';
export { default as CreatorHeart } from './classes/comments/CreatorHeart.js';
export { default as EmojiPicker } from './classes/comments/EmojiPicker.js';
export { default as PdgCommentChip } from './classes/comments/PdgCommentChip.js';
@@ -210,6 +212,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 LockupMetadataView } from './classes/LockupMetadataView.js';
export { default as LockupView } from './classes/LockupView.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';
@@ -367,7 +371,10 @@ export { default as Tab } from './classes/Tab.js';
export { default as Tabbed } from './classes/Tabbed.js';
export { default as TabbedSearchResults } from './classes/TabbedSearchResults.js';
export { default as TextHeader } from './classes/TextHeader.js';
export { default as ThumbnailBadgeView } from './classes/ThumbnailBadgeView.js';
export { default as ThumbnailHoverOverlayView } from './classes/ThumbnailHoverOverlayView.js';
export { default as ThumbnailLandscapePortrait } from './classes/ThumbnailLandscapePortrait.js';
export { default as ThumbnailOverlayBadgeView } from './classes/ThumbnailOverlayBadgeView.js';
export { default as ThumbnailOverlayBottomPanel } from './classes/ThumbnailOverlayBottomPanel.js';
export { default as ThumbnailOverlayEndorsement } from './classes/ThumbnailOverlayEndorsement.js';
export { default as ThumbnailOverlayHoverText } from './classes/ThumbnailOverlayHoverText.js';
@@ -380,6 +387,7 @@ export { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOv
export { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel.js';
export { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus.js';
export { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton.js';
export { default as ThumbnailView } from './classes/ThumbnailView.js';
export { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration.js';
export { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader.js';
export { default as ToggleButton } from './classes/ToggleButton.js';

View File

@@ -25,6 +25,7 @@ import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem.j
import Format from './classes/misc/Format.js';
import VideoDetails from './classes/misc/VideoDetails.js';
import NavigationEndpoint from './classes/NavigationEndpoint.js';
import CommentView from './classes/comments/CommentView.js';
import type { KeyInfo } from './generator.js';
import type { ObservedArray, YTNodeConstructor, YTNode } from './helpers.js';
@@ -43,7 +44,8 @@ export type ParserError = {
classdata: RawNode,
error: unknown
} | {
error_type: 'mutation_data_missing'
error_type: 'mutation_data_missing',
classname: string
} | {
error_type: 'mutation_data_invalid',
total: number,
@@ -108,7 +110,7 @@ let ERROR_HANDLER: ParserErrorHandler = ({ classname, ...context }: ParserError)
case 'mutation_data_missing':
Log.warn(TAG,
new InnertubeError(
'Mutation data required for processing MusicMultiSelectMenuItems, but none found.\n' +
`Mutation data required for processing ${classname}, but none found.\n` +
`This is a bug, please report it at ${Platform.shim.info.bugs_url}`
)
);
@@ -316,6 +318,10 @@ export function parseResponse<T extends IParsedResponse = IParsedResponse>(data:
applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
if (on_response_received_endpoints_memo) {
applyCommentsMutations(on_response_received_endpoints_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
}
const continuation = data.continuation ? parseC(data.continuation) : null;
if (continuation) {
parsed_data.continuation = continuation;
@@ -683,3 +689,31 @@ export function applyMutations(memo: Memo, mutations: RawNode[]) {
}
}
}
export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {
const comment_view_items = memo.getType(CommentView);
if (comment_view_items.length > 0) {
if (!mutations) {
ERROR_HANDLER({
error_type: 'mutation_data_missing',
classname: 'CommentView'
});
}
for (const comment_view of comment_view_items) {
const comment_mutation = mutations
.find((mutation) => mutation.payload?.commentEntityPayload?.key === comment_view.keys.comment)
?.payload?.commentEntityPayload;
const toolbar_state_mutation = mutations
.find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)
?.payload?.engagementToolbarStateEntityPayload;
const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)
?.payload?.engagementToolbarSurfaceEntityPayload;
comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar);
}
}
}

View File

@@ -8,6 +8,7 @@ import PlaylistMetadata from '../classes/PlaylistMetadata.js';
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo.js';
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo.js';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail.js';
import ReelItem from '../classes/ReelItem.js';
import VideoOwner from '../classes/VideoOwner.js';
import Alert from '../classes/Alert.js';
import ContinuationItem from '../classes/ContinuationItem.js';
@@ -66,8 +67,8 @@ export default class Playlist extends Feed<IBrowseResponse> {
return primary_info.stats[index]?.toString() || 'N/A';
}
get items(): ObservedArray<PlaylistVideo> {
return observe(this.videos.as(PlaylistVideo).filter((video) => video.style !== 'PLAYLIST_VIDEO_RENDERER_STYLE_RECOMMENDED_VIDEO'));
get items(): ObservedArray<PlaylistVideo | ReelItem> {
return observe(this.videos.as(PlaylistVideo, ReelItem).filter((video) => (video as PlaylistVideo).style !== 'PLAYLIST_VIDEO_RENDERER_STYLE_RECOMMENDED_VIDEO'));
}
get has_continuation() {

71
src/platform/cf-worker.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ICache } from '../types/Cache.js';
import { Platform } from '../utils/Utils.js';
import evaluate from './jsruntime/jinter.js';
import { $INLINE_JSON } from 'ts-transformer-inline-file';
import sha1Hash from './polyfills/web-crypto.js';
const { homepage, version, bugs } = $INLINE_JSON('../../package.json');
const repo_url = homepage?.split('#')[0];
class Cache implements ICache {
#persistent_directory: string;
#persistent: boolean;
constructor(persistent = false, persistent_directory?: string) {
this.#persistent_directory = persistent_directory || '';
this.#persistent = persistent;
}
get cache_dir() {
return this.#persistent ? this.#persistent_directory : '';
}
async get(key: string) {
const cache = await caches.open('yt-api');
const response = await cache.match(key);
if (!response) return undefined;
return response.arrayBuffer();
}
async set(key: string, value: ArrayBuffer) {
const cache = await caches.open('yt-api');
cache.put(key, new Response(value));
}
async remove(key: string) {
const cache = await caches.open('yt-api');
await cache.delete(key);
}
}
Platform.load({
runtime: 'cf-worker',
info: {
version: version,
bugs_url: bugs?.url || `${repo_url}/issues`,
repo_url
},
server: true,
Cache: Cache,
sha1Hash,
uuidv4() {
return crypto.randomUUID();
},
eval: evaluate,
fetch: fetch.bind(globalThis),
Request: Request,
Response: Response,
Headers: Headers,
FormData: FormData,
File: File,
ReadableStream: ReadableStream,
CustomEvent: CustomEvent
});
export * from './lib.js';
import Innertube from './lib.js';
export default Innertube;

View File

@@ -56,8 +56,14 @@ class Cache implements ICache {
const request = db.transaction('kv-store', 'readonly').objectStore('kv-store').get(key);
request.onerror = reject;
request.onsuccess = function () {
const result: Uint8Array | undefined = this.result?.v;
resolve(result ? result.buffer : undefined);
const result: unknown = this.result?.v;
if (result instanceof ArrayBuffer) {
resolve(result);
} else if (ArrayBuffer.isView(result)) {
resolve(result.buffer);
} else {
resolve(undefined);
}
};
});
}

View File

@@ -0,0 +1,75 @@
import {
tsValueToJsonValueFns,
jsonValueToTsValueFns,
} from "../../../runtime/json/scalar.js";
import {
WireMessage,
} from "../../../runtime/wire/index.js";
import {
default as serialize,
} from "../../../runtime/wire/serialize.js";
import {
tsValueToWireValueFns,
wireValueToTsValueFns,
} from "../../../runtime/wire/scalar.js";
import {
default as deserialize,
} from "../../../runtime/wire/deserialize.js";
export declare namespace $.youtube.ShortsParam {
export type Field1 = {
p1: number;
}
}
export type Type = $.youtube.ShortsParam.Field1;
export function getDefaultValue(): $.youtube.ShortsParam.Field1 {
return {
p1: 0,
};
}
export function createValue(partialValue: Partial<$.youtube.ShortsParam.Field1>): $.youtube.ShortsParam.Field1 {
return {
...getDefaultValue(),
...partialValue,
};
}
export function encodeJson(value: $.youtube.ShortsParam.Field1): unknown {
const result: any = {};
if (value.p1 !== undefined) result.p1 = tsValueToJsonValueFns.int32(value.p1);
return result;
}
export function decodeJson(value: any): $.youtube.ShortsParam.Field1 {
const result = getDefaultValue();
if (value.p1 !== undefined) result.p1 = jsonValueToTsValueFns.int32(value.p1);
return result;
}
export function encodeBinary(value: $.youtube.ShortsParam.Field1): Uint8Array {
const result: WireMessage = [];
if (value.p1 !== undefined) {
const tsValue = value.p1;
result.push(
[1, tsValueToWireValueFns.int32(tsValue)],
);
}
return serialize(result);
}
export function decodeBinary(binary: Uint8Array): $.youtube.ShortsParam.Field1 {
const result = getDefaultValue();
const wireMessage = deserialize(binary);
const wireFields = new Map(wireMessage);
field: {
const wireValue = wireFields.get(1);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.p1 = value;
}
return result;
}

View File

@@ -0,0 +1 @@
export type { Type as Field1 } from "./Field1.js";

View File

@@ -0,0 +1,100 @@
import {
Type as Field1,
encodeJson as encodeJson_1,
decodeJson as decodeJson_1,
encodeBinary as encodeBinary_1,
decodeBinary as decodeBinary_1,
} from "./(ShortsParam)/Field1.js";
import {
tsValueToJsonValueFns,
jsonValueToTsValueFns,
} from "../../runtime/json/scalar.js";
import {
WireMessage,
WireType,
} from "../../runtime/wire/index.js";
import {
default as serialize,
} from "../../runtime/wire/serialize.js";
import {
tsValueToWireValueFns,
wireValueToTsValueFns,
} from "../../runtime/wire/scalar.js";
import {
default as deserialize,
} from "../../runtime/wire/deserialize.js";
export declare namespace $.youtube {
export type ShortsParam = {
f1?: Field1;
p59: number;
}
}
export type Type = $.youtube.ShortsParam;
export function getDefaultValue(): $.youtube.ShortsParam {
return {
f1: undefined,
p59: 0,
};
}
export function createValue(partialValue: Partial<$.youtube.ShortsParam>): $.youtube.ShortsParam {
return {
...getDefaultValue(),
...partialValue,
};
}
export function encodeJson(value: $.youtube.ShortsParam): unknown {
const result: any = {};
if (value.f1 !== undefined) result.f1 = encodeJson_1(value.f1);
if (value.p59 !== undefined) result.p59 = tsValueToJsonValueFns.int32(value.p59);
return result;
}
export function decodeJson(value: any): $.youtube.ShortsParam {
const result = getDefaultValue();
if (value.f1 !== undefined) result.f1 = decodeJson_1(value.f1);
if (value.p59 !== undefined) result.p59 = jsonValueToTsValueFns.int32(value.p59);
return result;
}
export function encodeBinary(value: $.youtube.ShortsParam): Uint8Array {
const result: WireMessage = [];
if (value.f1 !== undefined) {
const tsValue = value.f1;
result.push(
[1, { type: WireType.LengthDelimited as const, value: encodeBinary_1(tsValue) }],
);
}
if (value.p59 !== undefined) {
const tsValue = value.p59;
result.push(
[59, tsValueToWireValueFns.int32(tsValue)],
);
}
return serialize(result);
}
export function decodeBinary(binary: Uint8Array): $.youtube.ShortsParam {
const result = getDefaultValue();
const wireMessage = deserialize(binary);
const wireFields = new Map(wireMessage);
field: {
const wireValue = wireFields.get(1);
if (wireValue === undefined) break field;
const value = wireValue.type === WireType.LengthDelimited ? decodeBinary_1(wireValue.value) : undefined;
if (value === undefined) break field;
result.f1 = value;
}
field: {
const wireValue = wireFields.get(59);
if (wireValue === undefined) break field;
const value = wireValueToTsValueFns.int32(wireValue);
if (value === undefined) break field;
result.p59 = value;
}
return result;
}

View File

@@ -11,3 +11,4 @@ export type { Type as MusicSearchFilter } from "./MusicSearchFilter.js";
export type { Type as SearchFilter } from "./SearchFilter.js";
export type { Type as Hashtag } from "./Hashtag.js";
export type { Type as ReelSequence } from "./ReelSequence.js";
export type { Type as ShortsParam } from "./ShortsParam.js";

View File

@@ -15,6 +15,7 @@ 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';
import * as ReelSequence from './generated/messages/youtube/ReelSequence.js';
import * as ShortsParam from './generated/messages/youtube/ShortsParam.js';
export function encodeVisitorData(id: string, timestamp: number): string {
const buf = VisitorData.encodeBinary({ id, timestamp });
@@ -341,4 +342,14 @@ export function encodeReelSequence(short_id: string): string {
feature3: 0
});
return encodeURIComponent(u8ToBase64(buf));
}
export function encodeShortsParam() {
const buf = ShortsParam.encodeBinary({
f1: {
p1: 1
},
p59: 1
});
return encodeURIComponent(u8ToBase64(buf));
}

View File

@@ -275,4 +275,12 @@ message ReelSequence {
required Params params = 5;
required int32 feature_2 = 10;
required int32 feature_3 = 13;
}
message ShortsParam {
message Field1 {
int32 p1 = 1;
}
Field1 f1 = 1;
int32 p59 = 59;
}

View File

@@ -1,6 +1,6 @@
import type { ICacheConstructor } from './Cache.js';
export type Runtime = 'deno' | 'node' | 'browser' | 'unknown';
export type Runtime = 'deno' | 'node' | 'browser' | 'cf-worker' | 'unknown';
export type FetchFunction = typeof fetch;

View File

@@ -56,9 +56,9 @@ export const CLIENTS = Object.freeze({
},
ANDROID: {
NAME: 'ANDROID',
VERSION: '18.06.35',
SDK_VERSION: '29',
USER_AGENT: 'com.google.android.youtube/18.06.35 (Linux; U; Android 10; US)'
VERSION: '18.48.37',
SDK_VERSION: 33,
USER_AGENT: 'com.google.android.youtube/18.48.37(Linux; U; Android 13; en_US; sdk_gphone64_x86_64 Build/UPB4.230623.005) gzip'
},
YTSTUDIO_ANDROID: {
NAME: 'ANDROID_CREATOR',

View File

@@ -169,9 +169,9 @@ export function chooseFormat(options: FormatOptions, streaming_data?: IStreaming
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.has_text && format.language === language;
}
return !format.has_video && format.is_original;
return !format.has_video && !format.has_text && format.is_original;
});
if (audio_only.length > 0) {

View File

@@ -65,7 +65,6 @@ export default class HTTPClient {
request_headers.set('origin', request_url.origin);
}
request_url.searchParams.set('key', this.#session.key);
request_url.searchParams.set('prettyPrint', 'false');
request_url.searchParams.set('alt', 'json');
@@ -96,6 +95,7 @@ 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);
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
} else if (n_body.context.client.clientName === 'iOS') {
request_headers.set('User-Agent', Constants.CLIENTS.iOS.USER_AGENT);
}
@@ -103,6 +103,14 @@ export default class HTTPClient {
is_web_kids = n_body.context.client.clientName === 'WEB_KIDS';
request_body = JSON.stringify(n_body);
} else if (content_type === 'application/x-protobuf') {
// Assume it is always an Android request.
if (Platform.shim.server) {
request_headers.set('User-Agent', Constants.CLIENTS.ANDROID.USER_AGENT);
request_headers.set('X-GOOG-API-FORMAT-VERSION', '2');
request_headers.delete('X-Youtube-Client-Version');
request_headers.delete('X-Origin');
}
}
// Authenticate (NOTE: YouTube Kids does not support regular bearer tokens)
@@ -113,9 +121,6 @@ export default class HTTPClient {
await oauth.refreshIfRequired();
request_headers.set('authorization', `Bearer ${oauth.credentials.access_token}`);
// Remove API key as it is not required when using oauth.
request_url.searchParams.delete('key');
}
if (this.#cookie) {
@@ -135,8 +140,8 @@ export default class HTTPClient {
const response = await this.#fetch(request, {
body: request_body,
headers: request_headers,
credentials: 'include',
redirect: input instanceof Platform.shim.Request ? input.redirect : init?.redirect || 'follow'
redirect: input instanceof Platform.shim.Request ? input.redirect : init?.redirect || 'follow',
...(Platform.shim.runtime !== 'cf-worker' ? { credentials: 'include' } : {})
});
// Check if 2xx
@@ -156,7 +161,7 @@ export default class HTTPClient {
ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION;
ctx.client.userAgent = Constants.CLIENTS.ANDROID.USER_AGENT;
ctx.client.osName = 'Android';
ctx.client.osVersion = '10';
ctx.client.osVersion = '13';
ctx.client.platform = 'MOBILE';
}

View File

@@ -465,9 +465,6 @@ function getColorInfo(format: Format) {
if (color_info.transfer_characteristics) {
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS[color_info.transfer_characteristics];
} else if (getStringBetweenStrings(format.mime_type, 'codecs="', '"')?.startsWith('avc1')) {
// YouTube's h264 streams always seem to be SDR, so this is a pretty safe bet.
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS.BT709;
}
if (color_info.matrix_coefficients) {
@@ -486,6 +483,9 @@ function getColorInfo(format: Format) {
+ `InnerTube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat);
}
}
} else if (getStringBetweenStrings(format.mime_type, 'codecs="', '"')?.startsWith('avc1')) {
// YouTube's h264 streams always seem to be SDR, so this is a pretty safe bet.
transfer_characteristics = COLOR_TRANSFER_CHARACTERISTICS.BT709;
}
const info: ColorInfo = {

View File

@@ -1,8 +1,10 @@
import { createWriteStream, existsSync } from 'node:fs';
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
jest.useRealTimers();
describe('YouTube.js Tests', () => {
let innertube: Innertube;
let innertube: Innertube;
beforeAll(async () => {
innertube = await Innertube.create({ generate_session_locally: true });
@@ -19,6 +21,11 @@ describe('YouTube.js Tests', () => {
expect(info.basic_info.id).toBe('bUHZ2k9DYHY');
});
test('Innertube#getBasicInfo (Android)', async () => {
const info = await innertube.getBasicInfo('ksEYRaIpP7A');
expect(info.basic_info.id).toBe('ksEYRaIpP7A');
});
test('Innertube#getShortsWatchItem', async () => {
const info = await innertube.getShortsWatchItem('jOydBrmmjfk');
expect(info.watch_next_feed?.length).toBeGreaterThan(0);
@@ -29,7 +36,7 @@ describe('YouTube.js Tests', () => {
expect(info.watch_next_feed?.length).toBeGreaterThan(0);
const previousData = info.watch_next_feed?.map(value => value.as(YTNodes.Command).endpoint)
const cont = await info.getWatchNextContinuation()
expect(cont.watch_next_feed?.length).toBeGreaterThan(0);
const newData = cont.watch_next_feed?.map(value => value.as(YTNodes.Command).endpoint)
expect(previousData).not.toEqual(newData)
@@ -124,21 +131,25 @@ describe('YouTube.js Tests', () => {
});
});
describe('Innertube#getHomeFeed', () => {
let home_feed: YT.HomeFeed;
test('Innertube#getHomeFeed', async () => {
const home_feed = await innertube.getHomeFeed();
expect(home_feed).toBeDefined();
expect(home_feed.contents).toBeDefined();
expect(home_feed.contents.contents?.length).toBeGreaterThan(0);
beforeAll(async () => {
home_feed = await innertube.getHomeFeed();
expect(home_feed).toBeDefined();
expect(home_feed.contents).toBeDefined();
expect(home_feed.contents.contents?.length).toBeGreaterThan(0);
});
// YouTube tells anonymous users to sign in or search something first before showing them a valid home feed.
// Otherwise, you get the following message:
//
// "Try searching to get started
// Start watching videos to help us build a feed of videos you'll love"
//
// - Removing this test for now.
test('HomeFeed#getContinuation', async () => {
const incremental_continuation = await home_feed.getContinuation();
expect(incremental_continuation.contents).toBeDefined();
expect(incremental_continuation.contents.contents?.length).toBeGreaterThan(0);
});
// test('HomeFeed#getContinuation', async () => {
// const incremental_continuation = await home_feed.getContinuation();
// expect(incremental_continuation.contents).toBeDefined();
// expect(incremental_continuation.contents.contents?.length).toBeGreaterThan(0);
// });
});
test('Innertube#getGuide', async () => {
@@ -335,7 +346,7 @@ describe('YouTube.js Tests', () => {
expect(incremental_continuation.sections).toBeDefined();
expect(incremental_continuation.sections?.length).toBeGreaterThan(0);
});
test('HomeFeed#applyFilter', async () => {
home = await home.applyFilter(home.filters[1]);
expect(home).toBeDefined();

View File

@@ -106,7 +106,8 @@
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js"
"src/**/*.js",
"scripts/**/*.mjs",
],
"exclude": [
"node_modules",