Files
YouTube.js/lib/parser/index.ts
Daniel Wykerd fb68e6bcfe feat!: better cross runtime support (#97)
* refactor: remove dependancies

removes node-forge and uuid in favor of Web APIs

* refactor!: commonjs to es6

To aid with #93 I will make all my changes in TypeScript instead.
This is the first step into making that happen.

Used: https://github.com/wessberg/cjstoesm

* refactor!: NToken and Signature TS files

Bring this PR up to speed with #93

* feat: cross platform cache (WIP)

this is untested!
should remove idb as dependecy.

* feat: EventEmitter polyfill

* refactor: remove events

* feat: HTTPClient based on Fetch API (WIP)

* refactor!: parsers refactor (WIP)

Initial TS support for parsers as per #93

This adds several type safety checks to the parser which'll help to
ensure valid data is returned by the parser.

* refactor!: parsers refactor (WIP)

Bring more in line with the existing implementations & make less verbose

* refactor!: parser refactor

I was overcomplicating things, this is much simpler and compatible with
the existing JS API

* fix: some missed parsers while refactoring

* fix: better type inferance for parseResponse

* feat(TS): typesafe YTNode casts

* feat: more type safety in YTNode and Parser

* refactor: VideoInfo download with fetch & TS (WIP)

Again, this also does some work for #93

* fix: LiveChat in VideoInfo

* refactor!: more typesafety in parser

* refactor!: VideoInfo almost completed

* refactor!: player and session refactors

- Remove the Player class' dependance on Session.
- Add additional context to the Session.

* refactor!: move auth logic to Session (WIP)

* refactor: TS port for Actions and Innertube

My fingers hurt from typing out all those types :-P

* refactor: NavigationEndpoint TS

this is still a WIP and should be improved.
NavigationEndpoint should probably be refactored further.

* refactor!: VideoInfo compiles without errors

* chore: delete old player

* fix: import errors

It compiles and runs!!

* fix: Utils import fixes

* fix: several runtime errors

* fix: video streaming

* chore: remove console.log debugging

Whoops, forgot to remove these before I pushed the previous commit

* chore: remove old unused dependencies

* fix: typescript errors

Now emitting declarations and source maps

* refactor: TS feed

* chore: delete old Feed

* refactor: move streamToIterable into Utils

* refactor: AccountManager TS

* refactor: FilterableFeed to TS

* refactor: InteractionManager to TS

* refactor: PlaylistManager to TS

* refactor: TabbedFeed to TS

* refactor: Music to TS (WIP)

more work to be done, see TODO comments

* fix: getting the tests to pass (6/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1152 ms)
      ✕ Should search on YouTube Music (705 ms)
      ✕ Should retrieve YouTube search suggestions (722 ms)
      ✓ Should retrieve YouTube Music search suggestions (233 ms)
    Comments
      ✓ Should retrieve comments (585 ms)
      ✕ Should retrieve next batch of comments (221 ms)
      ✕ Should retrieve comment replies (1 ms)
    General
      ✕ Should retrieve playlist with YouTube (732 ms)
      ✓ Should retrieve home feed (838 ms)
      ✓ Should retrieve trending content (543 ms)
      ✓ Should retrieve video info (639 ms)
      ✕ Should download video (5 ms)

* fix: tests (7/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1984 ms)
      ✕ Should search on YouTube Music (1139 ms)
      ✕ Should retrieve YouTube search suggestions (1433 ms)
      ✓ Should retrieve YouTube Music search suggestions (529 ms)
    Comments
      ✓ Should retrieve comments (324 ms)
      ✓ Should retrieve next batch of comments (395 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (653 ms)
      ✓ Should retrieve home feed (1085 ms)
      ✓ Should retrieve trending content (513 ms)
      ✓ Should retrieve video info (921 ms)
      ✕ Should download video (3 ms)

* fix: download tests (8/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1293 ms)
      ✕ Should search on YouTube Music (927 ms)
      ✕ Should retrieve YouTube search suggestions (1250 ms)
      ✓ Should retrieve YouTube Music search suggestions (258 ms)
    Comments
      ✓ Should retrieve comments (803 ms)
      ✓ Should retrieve next batch of comments (511 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (528 ms)
      ✓ Should retrieve home feed (1047 ms)
      ✓ Should retrieve trending content (548 ms)
      ✓ Should retrieve video info (825 ms)
      ✓ Should download video (1779 ms)

* fix: tests (9/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1276 ms)
      ✕ Should search on YouTube Music (955 ms)
      ✓ Should retrieve YouTube search suggestions (661 ms)
      ✓ Should retrieve YouTube Music search suggestions (491 ms)
    Comments
      ✓ Should retrieve comments (624 ms)
      ✓ Should retrieve next batch of comments (353 ms)
      ✕ Should retrieve comment replies
    General
      ✕ Should retrieve playlist with YouTube (672 ms)
      ✓ Should retrieve home feed (1277 ms)
      ✓ Should retrieve trending content (999 ms)
      ✓ Should retrieve video info (1106 ms)
      ✓ Should download video (2514 ms)

* feat: key based type validation for parsers

* fix: comments tests pass (10/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (938 ms)
      ✕ Should search on YouTube Music (850 ms)
      ✓ Should retrieve YouTube search suggestions (528 ms)
      ✓ Should retrieve YouTube Music search suggestions (224 ms)
    Comments
      ✓ Should retrieve comments (518 ms)
      ✓ Should retrieve next batch of comments (337 ms)
      ✓ Should retrieve comment replies (358 ms)
    General
      ✕ Should retrieve playlist with YouTube (466 ms)
      ✓ Should retrieve home feed (1051 ms)
      ✓ Should retrieve trending content (623 ms)
      ✓ Should retrieve video info (863 ms)
      ✓ Should download video (2656 ms)

* refactor: type safety checks removing @ts-ignore

* fix: playlist tests pass (11/12)

YouTube.js Tests
    Search
      ✓ Should search on YouTube (991 ms)
      ✕ Should search on YouTube Music (924 ms)
      ✓ Should retrieve YouTube search suggestions (606 ms)
      ✓ Should retrieve YouTube Music search suggestions (225 ms)
    Comments
      ✓ Should retrieve comments (393 ms)
      ✓ Should retrieve next batch of comments (284 ms)
      ✓ Should retrieve comment replies (252 ms)
    General
      ✓ Should retrieve playlist with YouTube (578 ms)
      ✓ Should retrieve home feed (1148 ms)
      ✓ Should retrieve trending content (541 ms)
      ✓ Should retrieve video info (799 ms)
      ✓ Should download video (1419 ms)

* fix: all tests pass for node 🎉

YouTube.js Tests
    Search
      ✓ Should search on YouTube (1053 ms)
      ✓ Should search on YouTube Music (761 ms)
      ✓ Should retrieve YouTube search suggestions (453 ms)
      ✓ Should retrieve YouTube Music search suggestions (221 ms)
    Comments
      ✓ Should retrieve comments (627 ms)
      ✓ Should retrieve next batch of comments (412 ms)
      ✓ Should retrieve comment replies (268 ms)
    General
      ✓ Should retrieve playlist with YouTube (565 ms)
      ✓ Should retrieve home feed (775 ms)
      ✓ Should retrieve trending content (498 ms)
      ✓ Should retrieve video info (875 ms)
      ✓ Should download video (1364 ms)

* build: working Deno bundle

Still need to test whether this bundle works in the browser

* docs: update deno example to download video

* refactor: MusicResponsiveListItem to TS

* docs: TSDoc for Parser helpers

* docs: Parser documentation for TS

* docs: add note about parseItem and parseArray

* test: remove browser tests since they're identical

* feat: browser support and proxy example

* fix: PlaylistManager TS after merge

* feat: in-browser video streaming

* refactor: cleanup the Dash example

* feat: allow custom fetch implementations

* feat: fetch debugger

* fix: OAuth login

* refactor: remove file extensions from imports

* refactor: build scripts

* fix: CustomEvent on node

* fix: LiveChat

* fix: linting

* fix: liniting in build-parser-json

* chore: update test workflow

* fix: NToken errors after lint fixes

* fix: codacy complaints

* docs: update to reflect changes

Definitly needs more work but its a start

* refactor: cleanup imports/exports

* fix: browser example

- Remove user-agent before making request.
- Fix cache on browsers

* fix: cache on node

* fix: stupid mistake

* refactor: Session#signIn to wait untill success

This also splits the 'auth' event up into 3 distinct events:
- 'auth' -> fired on success
- 'auth-pending' -> fired when pending authentication
- 'auth-error' -> fired when an error occurred

* refactor: freeze Constants

* refactor: cleanup HTTPClient Request

* refactor: debugFetch readability

* chore: lint

* refactor: replace jsdoc with tsdoc eslint plugin

remove @param annotations without descriptions

* fix: bunch of liniting warnings

* refactor: better inference on YTNode#is

As suggested by @MasterOfBob777

* fix: linting warnings

* revert: undici import

* refactor: rename `list_type` to `item_type`
2022-07-20 14:06:12 -03:00

304 lines
12 KiB
TypeScript

import { InnertubeError, ParsingError } from '../utils/Utils';
import Format from './classes/misc/Format';
import VideoDetails from './classes/misc/VideoDetails';
import GetParserByName from './map';
import package_json from '../../package.json';
import Endscreen from './classes/Endscreen';
import CardCollection from './classes/CardCollection';
import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, Memo } from './helpers';
export class AppendContinuationItemsAction extends YTNode {
static readonly type = 'appendContinuationItemsAction';
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
super();
this.contents = Parser.parse(data.continuationItems, true);
}
}
export class ReloadContinuationItemsCommand extends YTNode {
static readonly type = 'reloadContinuationItemsCommand';
target_id: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
super();
this.target_id = data.targetId;
this.contents = Parser.parse(data.continuationItems, true);
}
}
export class SectionListContinuation extends YTNode {
static readonly type = 'sectionListContinuation';
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation = data.continuations[0].nextContinuationData.continuation;
}
}
export class TimedContinuation extends YTNode {
static readonly type = 'timedContinuationData';
timeout_ms: number; // TODO: is this a number or a string?
token: string;
constructor(data: any) {
super();
this.timeout_ms = data.timeoutMs || data.timeUntilLastMessageMsec;
this.token = data.continuation;
}
}
export class LiveChatContinuation extends YTNode {
static readonly type = 'liveChatContinuation';
actions: ObservedArray<YTNode>;
action_panel: YTNode | null;
item_list: YTNode | null;
header: YTNode | null;
participants_list: YTNode | null;
popout_message: YTNode | null;
emojis: any[] | null; // TODO: give this an actual type
continuation: TimedContinuation;
viewer_name: string;
constructor(data: any) {
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);
this.header = Parser.parseItem(data.header);
this.participants_list = Parser.parseItem(data.participantsList);
this.popout_message = Parser.parseItem(data.popoutMessage);
this.emojis = data.emojis?.map((emoji: any) => ({
emoji_id: emoji.emojiId,
shortcuts: emoji.shortcuts,
search_terms: emoji.searchTerms,
image: emoji.image,
is_custom_emoji: emoji.isCustomEmoji
})) || null;
this.continuation = new TimedContinuation(data.continuations?.[0].timedContinuationData ||
data.continuations?.[0].invalidationContinuationData ||
data.continuations?.[0].liveChatReplayContinuationData);
this.viewer_name = data.viewerName;
}
}
export default class Parser {
static #memo: Memo | null = null;
static #clearMemo() {
Parser.#memo = null;
}
static #createMemo() {
Parser.#memo = new Memo();
}
static #addToMemo(classname: string, result: YTNode) {
if (!Parser.#memo)
return;
const list = Parser.#memo.get(classname);
if (!list)
return Parser.#memo.set(classname, [ result ]);
list.push(result);
}
static #getMemo() {
if (!Parser.#memo)
throw new Error('Parser#getMemo() called before Parser#createMemo()');
return Parser.#memo;
}
/**
* Parses InnerTube response.
*/
static parseResponse(data: any) {
// Memoize the response objects by classname
this.#createMemo();
// TODO: is this parseItem?
const contents = Parser.parse(data.contents);
const contents_memo = this.#getMemo();
// End of memoization
this.#clearMemo();
this.#createMemo();
const on_response_received_actions = data.onResponseReceivedActions ? Parser.parseRR(data.onResponseReceivedActions) : null;
const on_response_received_actions_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const on_response_received_endpoints = data.onResponseReceivedEndpoints ? Parser.parseRR(data.onResponseReceivedEndpoints) : null;
const on_response_received_endpoints_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const on_response_received_commands = data.onResponseReceivedCommands ? Parser.parseRR(data.onResponseReceivedCommands) : null;
const on_response_received_commands_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const actions = data.actions ? Parser.parseActions(data.actions) : null;
const actions_memo = this.#getMemo();
this.#clearMemo();
return {
actions,
actions_memo,
contents,
contents_memo,
on_response_received_actions,
on_response_received_actions_memo,
on_response_received_endpoints,
on_response_received_endpoints_memo,
on_response_received_commands,
on_response_received_commands_memo,
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
continuation_contents: data.continuationContents ? Parser.parseLC(data.continuationContents) : null,
metadata: Parser.parse(data.metadata),
header: Parser.parse(data.header),
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
sidebar: Parser.parseItem(data.sidebar),
overlay: Parser.parseItem(data.overlay),
refinements: data.refinements || null,
estimated_results: data.estimatedResults || null,
player_overlays: Parser.parse(data.playerOverlays),
playability_status: data.playabilityStatus ? {
status: data.playabilityStatus.status as string,
error_screen: Parser.parse(data.playabilityStatus.errorScreen),
embeddable: !!data.playabilityStatus.playableInEmbed || false,
reason: `${data.reason}` || ''
} : undefined,
streaming_data: data.streamingData ? {
expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
formats: Parser.parseFormats(data.streamingData.formats),
adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats),
dash_manifest_url: data.streamingData?.dashManifestUrl || null,
dls_manifest_url: data.streamingData?.dashManifestUrl || null
} : undefined,
// TODO: PlayerCaptionsTracklist ?
captions: Parser.parse(data.captions),
video_details: data.videoDetails ? new VideoDetails(data.videoDetails) : undefined,
// TODO: might want to type check these two and use parseItem
annotations: Parser.parse(data.annotations),
storyboards: Parser.parse(data.storyboards),
endscreen: Parser.parseItem<Endscreen>(data.endscreen, Endscreen),
cards: Parser.parseItem<CardCollection>(data.cards, CardCollection)
};
}
static parseC(data: any) {
if (data.timedContinuationData)
return new TimedContinuation(data.timedContinuationData);
}
static parseLC(data: any) {
if (data.sectionListContinuation)
return new SectionListContinuation(data.sectionListContinuation);
if (data.liveChatContinuation)
return new LiveChatContinuation(data.liveChatContinuation);
}
static parseRR(actions: any[]) {
return observe(actions.map((action: any) => {
if (action.reloadContinuationItemsCommand)
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
if (action.appendContinuationItemsAction)
return new AppendContinuationItemsAction(action.appendContinuationItemsAction);
}).filter((item) => item) as (ReloadContinuationItemsCommand | AppendContinuationItemsAction)[]);
}
static parseActions(data: any) {
if (Array.isArray(data)) {
return Parser.parse(data.map((action) => {
delete action.clickTrackingParams;
return action;
}));
}
return new SuperParsedResult(Parser.parseItem(data));
}
static parseFormats(formats: any[]) {
return formats?.map((format) => new Format(format)) || [];
}
static parseItem<T extends YTNode = YTNode>(data: any, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (!data) return null;
const keys = Object.keys(data);
const classname = this.sanitizeClassName(keys[0]);
if (!this.shouldIgnore(classname)) {
try {
const TargetClass = GetParserByName(classname);
if (validTypes) {
if (Array.isArray(validTypes)) {
if (!validTypes.some((type) => type.type === TargetClass.type))
throw new ParsingError(`Type mismatch, got ${classname} but expected one of ${validTypes.map((type) => type.type).join(', ')}`);
} else if (TargetClass.type !== validTypes.type)
throw new ParsingError(`Type mismatch, got ${classname} but expected ${validTypes.type}`);
}
const result = new TargetClass(data[keys[0]]);
this.#addToMemo(classname, result);
return result as T;
} catch (err) {
this.formatError({ classname, classdata: data[keys[0]], err });
return null;
}
}
return null;
}
static parseArray<T extends YTNode = YTNode>(data: any[], validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (Array.isArray(data)) {
const results: T[] = [];
for (const item of data) {
const result = this.parseItem(item, validTypes);
if (result) {
results.push(result);
}
}
return observe(results);
} else if (!data) {
return observe([] as T[]);
}
throw new ParsingError('Expected array but got a single item');
}
static parse<T extends YTNode = YTNode>(data: any, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : ObservedArray<T> | null;
static parse<T extends YTNode = YTNode>(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) : SuperParsedResult<T>;
/**
* Parses the `contents` property of the response.
*/
static parse<T extends YTNode = YTNode>(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (!data)
return null;
if (Array.isArray(data)) {
const results: T[] = [];
for (const item of data) {
const result = this.parseItem(item, validTypes);
if (result) {
results.push(result);
}
}
const res = observe(results);
return requireArray ? res : new SuperParsedResult(observe(results));
} else if (requireArray) {
throw new ParsingError('Expected array but got a single item');
}
return new SuperParsedResult(this.parseItem(data, validTypes));
}
static formatError({ classname, classdata, err }: { classname: string, classdata: any, err: any }) {
if (err.code == 'MODULE_NOT_FOUND') {
return console.warn(new InnertubeError(`${classname} not found!\n` +
`This is a bug, please report it at ${package_json.bugs.url}`, classdata));
}
console.warn(new InnertubeError(`Something went wrong at ${classname}!\n` +
`This is a bug, please report it at ${package_json.bugs.url}`, { stack: err.stack }));
}
static sanitizeClassName(input: string) {
return (input.charAt(0).toUpperCase() + input.slice(1))
.replace(/Renderer|Model/g, '')
.replace(/Radio/g, 'Mix').trim();
}
static ignore_list = new Set<string>([
'DisplayAd',
'SearchPyv',
'MealbarPromo',
'BackgroundPromo',
'PromotedSparklesWeb',
'RunAttestationCommand',
'StatementBanner'
]);
static shouldIgnore(classname: string) {
return this.ignore_list.has(classname);
}
}
export type ParsedResponse = ReturnType<typeof Parser.parseResponse>;