feat: add support for YouTube Kids (#291)

* dev: add `WEB_KIDS` innertube client

* refactor: move DASH manifest stuff out of `VideoInfo`
This makes it easier to use these functions elsewhere.

* feat(ytkids): add `Kids#getInfo()` & `Kids#search()`

* feat: add `Innertube#kids.getHomeFeed()`

* docs: add YouTube Kids API ref

* docs: fix typo

* docs: fix yet another typo

* docs: update YouTube Music API ref
Unrelated but required to reflect changes made to the DASH manifest generation functions

* chore: lint

* chore: add tests

* feat: include `captions` in `VideoInfo`

* chore: fix tests
This commit is contained in:
LuanRT
2023-01-23 03:39:51 -03:00
committed by GitHub
parent 13ad3774c9
commit 2bbefefbb7
25 changed files with 1114 additions and 384 deletions

View File

@@ -0,0 +1,48 @@
import Feed from '../../core/Feed';
import Actions from '../../core/Actions';
import KidsCategoriesHeader from '../classes/ytkids/KidsCategoriesHeader';
import KidsCategoryTab from '../classes/ytkids/KidsCategoryTab';
import KidsHomeScreen from '../classes/ytkids/KidsHomeScreen';
import { InnertubeError } from '../../utils/Utils';
class HomeFeed extends Feed {
header?: KidsCategoriesHeader;
contents?: KidsHomeScreen;
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item().as(KidsCategoriesHeader);
this.contents = this.page.contents?.item().as(KidsHomeScreen);
}
/**
* Retrieves the contents of the given category tab. Use {@link HomeFeed.categories} to get a list of available categories.
* @param tab - The tab to select
*/
async selectCategoryTab(tab: string | KidsCategoryTab): Promise<HomeFeed> {
let target_tab: KidsCategoryTab | undefined;
if (typeof tab === 'string') {
target_tab = this.header?.category_tabs.find((t) => t.title.toString() === tab);
} else if (tab?.is(KidsCategoryTab)) {
target_tab = tab;
}
if (!target_tab)
throw new InnertubeError(`Tab "${tab}" not found`);
const page = await target_tab.endpoint.call(this.actions, { client: 'YTKIDS', parse: true });
// Copy over the header and header memo
page.header = this.page.header;
page.header_memo = this.page.header_memo;
return new HomeFeed(this.actions, page, true);
}
get categories(): string[] {
return this.header?.category_tabs.map((tab) => tab.title.toString()) || [];
}
}
export default HomeFeed;

View File

@@ -0,0 +1,24 @@
import Feed from '../../core/Feed';
import ItemSection from '../classes/ItemSection';
import { InnertubeError } from '../../utils/Utils';
import type Actions from '../../core/Actions';
import type { ObservedArray, YTNode } from '../helpers';
class Search extends Feed {
estimated_results: number | null;
contents: ObservedArray<YTNode> | null;
constructor(actions: Actions, data: any) {
super(actions, data);
this.estimated_results = this.page.estimated_results;
const item_section = this.memo.getType(ItemSection).first();
if (!item_section)
throw new InnertubeError('No item section found in search response.');
this.contents = item_section.contents;
}
}
export default Search;

View File

@@ -0,0 +1,137 @@
import Parser, { ParsedResponse } from '..';
import ItemSection from '../classes/ItemSection';
import NavigationEndpoint from '../classes/NavigationEndpoint';
import PlayerOverlay from '../classes/PlayerOverlay';
import SlimVideoMetadata from '../classes/SlimVideoMetadata';
import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults';
import type Format from '../classes/misc/Format';
import type Actions from '../../core/Actions';
import type { ApiResponse } from '../../core/Actions';
import type { ObservedArray, YTNode } from '../helpers';
import { Constants } from '../../utils';
import { InnertubeError } from '../../utils/Utils';
import FormatUtils, { DownloadOptions, FormatOptions, URLTransformer } from '../../utils/FormatUtils';
class VideoInfo {
#page: [ParsedResponse, ParsedResponse?];
#actions: Actions;
#cpn: string;
basic_info;
streaming_data;
playability_status;
captions;
#playback_tracking;
slim_video_metadata?: SlimVideoMetadata | null;
watch_next_feed?: ObservedArray<YTNode> | null;
current_video_endpoint?: NavigationEndpoint | null;
player_overlays?: PlayerOverlay;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
if (info.playability_status?.status === 'ERROR')
throw new InnertubeError('This video is unavailable', info.playability_status);
this.basic_info = info.video_details;
this.streaming_data = info.streaming_data;
this.playability_status = info.playability_status;
this.captions = info.captions;
this.#playback_tracking = info.playback_tracking;
const two_col = next?.contents.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;
const secondary_results = two_col?.secondary_results;
if (results && secondary_results) {
this.slim_video_metadata = results.firstOfType(ItemSection)?.contents?.firstOfType(SlimVideoMetadata);
this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents || secondary_results;
this.current_video_endpoint = next?.current_video_endpoint;
this.player_overlays = next?.player_overlays.item().as(PlayerOverlay);
}
}
/**
* Generates a DASH manifest from the streaming data.
* @param url_transformer - Function to transform the URLs.
* @returns DASH manifest
*/
toDash(url_transformer: URLTransformer = (url) => url): string {
return FormatUtils.toDash(this.streaming_data, url_transformer, this.#cpn, this.#actions.session.player);
}
/**
* Selects the format that best matches the given options.
* @param options - Options
*/
chooseFormat(options: FormatOptions): Format {
return FormatUtils.chooseFormat(options, this.streaming_data);
}
/**
* Downloads the video.
* @param options - Download options.
*/
async download(options: DownloadOptions = {}): Promise<ReadableStream<Uint8Array>> {
return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
}
/**
* Adds video to the watch history.
*/
async addToWatchHistory(): Promise<Response> {
if (!this.#playback_tracking)
throw new InnertubeError('Playback tracking not available');
const url_params = {
cpn: this.#cpn,
fmt: 251,
rtn: 0,
rt: 0
};
const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', 'https://www.');
const response = await this.#actions.stats(url, {
client_name: Constants.CLIENTS.WEB.NAME,
client_version: Constants.CLIENTS.WEB.VERSION
}, url_params);
return response;
}
/**
* Actions instance.
*/
get actions(): Actions {
return this.#actions;
}
/**
* Content Playback Nonce.
*/
get cpn(): string | undefined {
return this.#cpn;
}
get page(): [ParsedResponse, ParsedResponse?] {
return this.#page;
}
}
export default VideoInfo;