Compare commits

..

11 Commits

Author SHA1 Message Date
github-actions[bot]
8c688efb4a chore(main): release 5.4.0 (#438)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-14 00:00:43 -03:00
ChunkyProgrammer
cffa868c6e feat(parser): Add Quiz (#437) 2023-07-13 23:57:39 -03:00
ChunkyProgrammer
f267fcd8be Add getReleases and getPodcasts to Channel (#436)
* feat(Channel): Add `getReleases` method

* feat(Channel): Add `getPodcasts` method

* Fix(Playlist): Parse `PlaylistCustomThumbnail`
2023-07-13 15:25:20 -03:00
github-actions[bot]
23c22a93c4 chore(main): release 5.3.0 (#433)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-11 15:48:51 -03:00
absidue
1ca20836bf perf(Format): Cleanup the xtags parsing (#434) 2023-07-11 15:45:42 -03:00
absidue
5f058e69ae perf(toDash): Hoist duplicates from Representation to AdaptationSet (#431) 2023-07-11 15:22:02 -03:00
absidue
3500e92632 feat(toDash): Add color information (#430) 2023-07-10 21:25:48 -03:00
LuanRT
3f57c2fa5c refactor(PlayerEndpoint.ts): send specific params only if using Android based clients 2023-07-10 21:23:10 -03:00
LuanRT
7528ebdb60 chore: fix YouTube Music tests failing 2023-07-10 21:17:06 -03:00
github-actions[bot]
5e3846259f chore(main): release 5.2.1 (#429)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-03 22:30:29 -03:00
LuanRT
222dfce6bb fix: incorrect node parser implementations (#428)
These were causing some issues in v5.2.0.
2023-07-03 21:58:00 -03:00
24 changed files with 415 additions and 111 deletions

View File

@@ -1,5 +1,39 @@
# Changelog
## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)
### Features
* **Channel:** Add `getPodcasts()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
* **Channel:** Add `getReleases()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
* **parser:** Add `Quiz` ([#437](https://github.com/LuanRT/YouTube.js/issues/437)) ([cffa868](https://github.com/LuanRT/YouTube.js/commit/cffa868c6eeb579047653fac65da8e913fb3c621))
### Bug Fixes
* **Playlist:** Parse `PlaylistCustomThumbnail` for `thumbnail_renderer` ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
## [5.3.0](https://github.com/LuanRT/YouTube.js/compare/v5.2.1...v5.3.0) (2023-07-11)
### Features
* **toDash:** Add color information ([#430](https://github.com/LuanRT/YouTube.js/issues/430)) ([3500e92](https://github.com/LuanRT/YouTube.js/commit/3500e926327d560b1db036bfe503c276b91922ac))
### Performance Improvements
* **Format:** Cleanup the xtags parsing ([#434](https://github.com/LuanRT/YouTube.js/issues/434)) ([1ca2083](https://github.com/LuanRT/YouTube.js/commit/1ca20836bf343c78461fab7ad3b71db2b96e65c3))
* **toDash:** Hoist duplicates from Representation to AdaptationSet ([#431](https://github.com/LuanRT/YouTube.js/issues/431)) ([5f058e6](https://github.com/LuanRT/YouTube.js/commit/5f058e69ae8594491133f7f96287bea4137f7822))
## [5.2.1](https://github.com/LuanRT/YouTube.js/compare/v5.2.0...v5.2.1) (2023-07-04)
### Bug Fixes
* incorrect node parser implementations ([#428](https://github.com/LuanRT/YouTube.js/issues/428)) ([222dfce](https://github.com/LuanRT/YouTube.js/commit/222dfce6bbd13b2cd80ae11540cbc0edd9053fc5))
## [5.2.0](https://github.com/LuanRT/YouTube.js/compare/v5.1.0...v5.2.0) (2023-06-28)

View File

@@ -542,6 +542,8 @@ Retrieves contents for a given channel.
- `<channel>#getVideos()`
- `<channel>#getShorts()`
- `<channel>#getLiveStreams()`
- `<channel>#getReleases()`
- `<channel>#getPodcasts()`
- `<channel>#getPlaylists()`
- `<channel>#getHome()`
- `<channel>#getCommunity()`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "5.2.0",
"version": "5.4.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "5.2.0",
"version": "5.4.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "5.2.0",
"version": "5.4.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",

View File

@@ -8,6 +8,11 @@ export const PATH = '/player';
* @returns The payload.
*/
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
const is_android =
opts.client === 'ANDROID' ||
opts.client === 'YTMUSIC_ANDROID' ||
opts.client === 'YTSTUDIO_ANDROID';
return {
playbackContext: {
contentPlaybackContext: {
@@ -37,8 +42,8 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest {
...{
client: opts.client,
playlistId: opts.playlist_id,
// Workaround streaming URLs returning 403 when using Android clients and throttling in web clients.
params: '2AMBCgIQBg'
// Workaround streaming URLs returning 403 or getting throttled when using Android based clients.
params: is_android ? '2AMBCgIQBg' : opts.params
}
};
}

View File

@@ -7,14 +7,11 @@ export default class CarouselLockup extends YTNode {
static type = 'CarouselLockup';
info_rows: ObservedArray<InfoRow>;
video_lockup?: CompactVideo;
video_lockup?: CompactVideo | null;
constructor(data: RawNode) {
super();
this.info_rows = Parser.parseArray(data.infoRows, InfoRow);
const video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
if (video_lockup != null) {
this.video_lockup = video_lockup;
}
this.video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
}
}

View File

@@ -1,20 +1,25 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ContinuationItem from './ContinuationItem.js';
import EngagementPanelTitleHeader from './EngagementPanelTitleHeader.js';
import SectionList from './SectionList.js';
import StructuredDescriptionContent from './StructuredDescriptionContent.js';
export default class EngagementPanelSectionList extends YTNode {
static type = 'EngagementPanelSectionList';
target_id: String;
content?: SectionList|ContinuationItem|StructuredDescriptionContent;
header: EngagementPanelTitleHeader | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | null;
target_id?: string;
panel_identifier?: string;
visibility?: string;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent ]);
this.panel_identifier = data.panelIdentifier;
this.target_id = data.targetId;
const content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent ]);
if (content !== null) {
this.content = content;
}
this.visibility = data.visibility;
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Button from './Button.js';
export default class EngagementPanelTitleHeader extends YTNode {
static type = 'EngagementPanelTitleHeader';
title: Text;
visibility_button: Button | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.visibility_button = Parser.parseItem(data.visibilityButton, Button);
}
}

View File

@@ -1,22 +1,22 @@
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import { Text } from '../misc.js';
import type { RawNode } from '../index.js';
export default class ExpandableVideoDescriptionBody extends YTNode {
static type = 'ExpandableVideoDescriptionBody';
show_more_text: Text;
show_less_text: Text;
attributed_description_body_text: {
content: String
};
attributed_description_body_text?: string;
constructor(data: RawNode) {
super();
this.show_more_text = new Text(data.showMoreText);
this.show_less_text = new Text(data.showLessText);
this.attributed_description_body_text = {
content: data.attributedDescriptionBodyText.content
};
if (Reflect.has(data, 'attributedDescriptionBodyText')) {
this.attributed_description_body_text = data.attributedDescriptionBodyText?.content;
}
}
}

View File

@@ -4,6 +4,7 @@ import { Text } from '../misc.js';
export default class Factoid extends YTNode {
static type = 'Factoid';
label: Text;
value: Text;
accessibility_text: String;

View File

@@ -1,38 +1,29 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import { Text } from '../misc.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import type { RawNode } from '../index.js';
export default class InfoRow extends YTNode {
static type = 'InfoRow';
metadata_text?: Text;
metadata_endpoint?: NavigationEndpoint;
info_row_expand_status_key: String;
title: Text;
default_metadata?: Text;
expanded_metadata?: Text;
info_row_expand_status_key?: String;
constructor(data: RawNode) {
super();
if ('defaultMetadata' in data && 'runs' in data.defaultMetadata) {
const runs = data.defaultMetadata.runs;
if (runs.length > 0) {
const run = runs[0];
this.metadata_text = run?.text;
if ('navigationEndpoint' in run) {
this.metadata_endpoint = Parser.parseItem({ navigationEndpoint: run.navigationEndpoint }, NavigationEndpoint) || undefined;
}
}
}
if ('expandedMetadata' in data && 'runs' in data.expandedMetadata) {
this.metadata_text = new Text(data.expandedMetadata);
}
if (this.metadata_text === undefined) {
this.metadata_text = data.expandedMetadata?.simpleText
? new Text(data.expandedMetadata)
: data.defaultMetadata?.simpleText
? new Text(data.defaultMetadata)
: undefined;
}
this.info_row_expand_status_key = data.infoRowExpandStatusKey;
this.title = new Text(data.title);
if (Reflect.has(data, 'defaultMetadata')) {
this.default_metadata = new Text(data.defaultMetadata);
}
if (Reflect.has(data, 'expandedMetadata')) {
this.expanded_metadata = new Text(data.expandedMetadata);
}
if (Reflect.has(data, 'infoRowExpandStatusKey')) {
this.info_row_expand_status_key = data.infoRowExpandStatusKey;
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import PlaylistCustomThumbnail from './PlaylistCustomThumbnail.js';
import PlaylistVideoThumbnail from './PlaylistVideoThumbnail.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
@@ -13,7 +14,7 @@ export default class Playlist extends YTNode {
title: Text;
author: Text | Author;
thumbnails: Thumbnail[];
thumbnail_renderer?: PlaylistVideoThumbnail;
thumbnail_renderer?: PlaylistVideoThumbnail | PlaylistCustomThumbnail;
video_count: Text;
video_count_short: Text;
first_videos: ObservedArray<YTNode>;
@@ -44,7 +45,7 @@ export default class Playlist extends YTNode {
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
if (Reflect.has(data, 'thumbnailRenderer')) {
this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, PlaylistVideoThumbnail) || undefined;
this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, [ PlaylistVideoThumbnail, PlaylistCustomThumbnail ]) || undefined;
}
if (Reflect.has(data, 'viewPlaylistText')) {

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
export default class Quiz extends YTNode {
static type = 'Quiz';
choices: {
text: Text;
is_correct: boolean;
}[];
total_votes: Text;
constructor(data: RawNode) {
super();
this.choices = data.choices.map((choice: RawNode) => ({
text: new Text(choice.text),
is_correct: choice.isCorrect
}));
this.total_votes = new Text(data.totalVotes);
}
}

View File

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

View File

@@ -1,7 +1,6 @@
import { type ObservedArray, YTNode } from '../helpers.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import { Text } from '../misc.js';
import type { Thumbnail } from '../misc.js';
import { Text, Thumbnail } from '../misc.js';
import Factoid from './Factoid.js';
import NavigationEndpoint from './NavigationEndpoint.js';
@@ -10,7 +9,7 @@ export default class VideoDescriptionHeader extends YTNode {
channel: Text;
channel_navigation_endpoint?: NavigationEndpoint;
channel_thumbnails: String[];
channel_thumbnail: Thumbnail[];
factoids: ObservedArray<Factoid>;
publish_date: Text;
title: Text;
@@ -20,8 +19,8 @@ export default class VideoDescriptionHeader extends YTNode {
super();
this.title = new Text(data.title);
this.channel = new Text(data.channel);
this.channel_navigation_endpoint = Parser.parseItem(data.channelNavigationEndpoint, NavigationEndpoint) || undefined;
this.channel_thumbnails = data.channelThumbnail.thumbnails.map((thumbnail: Thumbnail) => thumbnail.url);
this.channel_navigation_endpoint = new NavigationEndpoint(data.channelNavigationEndpoint);
this.channel_thumbnail = Thumbnail.fromResponse(data.channelThumbnail);
this.publish_date = new Text(data.publishDate);
this.views = new Text(data.views);
this.factoids = Parser.parseArray(data.factoid, Factoid);

View File

@@ -0,0 +1,28 @@
import Parser, { type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
export default class VideoDescriptionInfocardsSection extends YTNode {
static type = 'VideoDescriptionInfocardsSection';
section_title: Text;
creator_videos_button: Button | null;
creator_about_button: Button | null;
section_subtitle: Text;
channel_avatar: Thumbnail[];
channel_endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.section_title = new Text(data.sectionTitle);
this.creator_videos_button = Parser.parseItem(data.creatorVideosButton, Button);
this.creator_about_button = Parser.parseItem(data.creatorAboutButton, Button);
this.section_subtitle = new Text(data.sectionSubtitle);
this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
this.channel_endpoint = new NavigationEndpoint(data.channelEndpoint);
}
}

View File

@@ -1,13 +1,14 @@
import { type ObservedArray, YTNode } from '../helpers.js';
import CarouselLockup from './CarouselLockup.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import { Text } from '../misc.js';
import CarouselLockup from './CarouselLockup.js';
export default class VideoDescriptionMusicSection extends YTNode {
static type = 'VideoDescriptionMusicSection';
carousel_lockups: ObservedArray<CarouselLockup>;
section_title: Text;
constructor(data: RawNode) {
super();
this.carousel_lockups = Parser.parseArray(data.carouselLockups, CarouselLockup);

View File

@@ -46,6 +46,12 @@ export default class Format {
is_descriptive?: boolean;
is_original?: boolean;
color_info?: {
primaries?: string;
transfer_characteristics?: string;
matrix_coefficients?: string;
};
constructor(data: RawNode) {
this.itag = data.itag;
this.mime_type = data.mimeType;
@@ -81,14 +87,24 @@ export default class Format {
this.has_audio = !!data.audioBitrate || !!data.audioQuality;
this.has_video = !!data.qualityLabel;
this.color_info = data.colorInfo ? {
primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
} : undefined;
if (this.has_audio) {
const args = new URLSearchParams(this.cipher || this.signature_cipher);
const url_components = new URLSearchParams(args.get('url') || this.url);
this.language = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('lang='))?.split('=').at(1) || null;
this.is_dubbed = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'dubbed';
this.is_descriptive = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'descriptive';
this.is_original = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'original' || !this.is_dubbed;
const xtags = url_components.get('xtags')?.split(':');
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;
this.is_dubbed = audio_content === 'dubbed';
this.is_descriptive = audio_content === 'descriptive';
this.is_original = audio_content === 'original' || !this.is_dubbed || !this.is_descriptive;
if (Reflect.has(data, 'audioTrack')) {
this.audio_track = {

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-cond-assign */
import { Platform } from '../utils/Utils.js';
import { InnertubeError, Platform } from '../utils/Utils.js';
import Author from './classes/misc/Author.js';
import Text from './classes/misc/Text.js';
import Thumbnail from './classes/misc/Thumbnail.js';
@@ -62,7 +62,9 @@ export class YTNodeGenerator {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
static #logNewClass(classname: string, key_info: KeyInfo) {
console.warn(`${classname} not found!\nThis is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!\nIntrospected and JIT generated this class in the meantime:\n${this.generateTypescriptClass(classname, key_info)}`);
console.warn(
new InnertubeError(`${classname} not found!\nThis is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!\nIntrospected and JIT generated this class in the meantime:\n${this.generateTypescriptClass(classname, key_info)}`)
);
}
static #logChangedKeys(classname: string, key_info: KeyInfo, changed_keys: KeyInfo) {
console.warn(`${classname} changed!\nThe following keys where altered: ${changed_keys.map(([ key ]) => this.#camelToSnake(key)).join(', ')}\nThe class has changed to:\n${this.generateTypescriptClass(classname, key_info)}`);
@@ -460,15 +462,15 @@ export class YTNodeGenerator {
* @returns The parsed value
*/
static parse(key: string, inference_type: InferenceType, data: any, key_path: string[] = [ 'data' ]) {
const should_optional = !inference_type.optional || this.#hasDataFromKeyPath({data}, [ ...key_path, key ]);
const should_optional = !inference_type.optional || this.#hasDataFromKeyPath({ data }, [ ...key_path, key ]);
switch (inference_type.type) {
case 'renderer':
{
return should_optional ? Parser.parseItem(this.#accessDataFromKeyPath({data}, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
return should_optional ? Parser.parseItem(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
}
case 'renderer_list':
{
return should_optional ? Parser.parse(this.#accessDataFromKeyPath({data}, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
return should_optional ? Parser.parse(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
}
case 'object':
{
@@ -482,25 +484,25 @@ export class YTNodeGenerator {
case 'misc':
switch (inference_type.misc_type) {
case 'NavigationEndpoint':
return should_optional ? new NavigationEndpoint(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? new NavigationEndpoint(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Text':
return should_optional ? new Text(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? new Text(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Thumbnail':
return should_optional ? Thumbnail.fromResponse(this.#accessDataFromKeyPath({data}, [ ...key_path, key ])) : undefined;
return should_optional ? Thumbnail.fromResponse(this.#accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
case 'Author':
{
const author_should_optional = !inference_type.optional || this.#hasDataFromKeyPath({data}, [ ...key_path, inference_type.params[0] ]);
const author_should_optional = !inference_type.optional || this.#hasDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]);
return author_should_optional ? new Author(
this.#accessDataFromKeyPath({data}, [ ...key_path, inference_type.params[0] ]),
this.#accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]),
inference_type.params[1] ?
this.#accessDataFromKeyPath({data}, [ ...key_path, inference_type.params[1] ]) : undefined
this.#accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[1] ]) : undefined
) : undefined;
}
}
throw new Error('Unreachable code reached! Switch missing case!');
case 'primative':
case 'unknown':
return this.#accessDataFromKeyPath({data}, [ ...key_path, key ]);
return this.#accessDataFromKeyPath({ data }, [ ...key_path, key ]);
}
}
static #passOne(classdata: any) {

View File

@@ -90,6 +90,7 @@ export { default as EndscreenElement } from './classes/EndscreenElement.js';
export { default as EndScreenPlaylist } from './classes/EndScreenPlaylist.js';
export { default as EndScreenVideo } from './classes/EndScreenVideo.js';
export { default as EngagementPanelSectionList } from './classes/EngagementPanelSectionList.js';
export { default as EngagementPanelTitleHeader } from './classes/EngagementPanelTitleHeader.js';
export { default as ExpandableMetadata } from './classes/ExpandableMetadata.js';
export { default as ExpandableTab } from './classes/ExpandableTab.js';
export { default as ExpandableVideoDescriptionBody } from './classes/ExpandableVideoDescriptionBody.js';
@@ -264,6 +265,7 @@ export { default as ProfileColumn } from './classes/ProfileColumn.js';
export { default as ProfileColumnStats } from './classes/ProfileColumnStats.js';
export { default as ProfileColumnStatsEntry } from './classes/ProfileColumnStatsEntry.js';
export { default as ProfileColumnUserInfo } from './classes/ProfileColumnUserInfo.js';
export { default as Quiz } from './classes/Quiz.js';
export { default as RecognitionShelf } from './classes/RecognitionShelf.js';
export { default as ReelItem } from './classes/ReelItem.js';
export { default as ReelShelf } from './classes/ReelShelf.js';
@@ -342,6 +344,7 @@ export { default as VerticalWatchCardList } from './classes/VerticalWatchCardLis
export { default as Video } from './classes/Video.js';
export { default as VideoCard } from './classes/VideoCard.js';
export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.js';
export { default as VideoDescriptionInfocardsSection } from './classes/VideoDescriptionInfocardsSection.js';
export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.js';
export { default as VideoInfoCardContent } from './classes/VideoInfoCardContent.js';
export { default as VideoOwner } from './classes/VideoOwner.js';

View File

@@ -163,6 +163,16 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return new Channel(this.actions, tab.page, true);
}
async getReleases(): Promise<Channel> {
const tab = await this.getTabByURL('releases');
return new Channel(this.actions, tab.page, true);
}
async getPodcasts(): Promise<Channel> {
const tab = await this.getTabByURL('podcasts');
return new Channel(this.actions, tab.page, true);
}
async getPlaylists(): Promise<Channel> {
const tab = await this.getTabByURL('playlists');
return new Channel(this.actions, tab.page, true);
@@ -217,6 +227,14 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return this.hasTabWithURL('streams');
}
get has_releases(): boolean {
return this.hasTabWithURL('releases');
}
get has_podcasts(): boolean {
return this.hasTabWithURL('podcasts');
}
get has_playlists(): boolean {
return this.hasTabWithURL('playlists');
}

View File

@@ -339,36 +339,45 @@ class VideoInfo extends MediaInfo {
* Get songs used in the video.
*/
get music_tracks() {
// @TODO: Refactor this.
const description_content = this.page[1]?.engagement_panels?.filter((panel) => panel.content?.is(StructuredDescriptionContent));
if (description_content !== undefined && description_content.length > 0) {
const music_section = (description_content[0].content as StructuredDescriptionContent)?.items.filter((item: YTNode) => item?.is(VideoDescriptionMusicSection)) as VideoDescriptionMusicSection[];
const music_section = description_content[0].content?.as(StructuredDescriptionContent)?.items?.filterType(VideoDescriptionMusicSection);
if (music_section !== undefined && music_section.length > 0) {
return music_section[0].carousel_lockups?.map((lookup) => {
let song, artist, album, license, videoId, channelId;
let song: string | undefined;
let artist: string | undefined;
let album: string | undefined;
let license: string | undefined;
let videoId: string | undefined;
let channelId: string | undefined;
// If the song isn't in the video_lockup, it should be in the info_rows
song = lookup.video_lockup?.title;
song = lookup.video_lockup?.title?.toString();
// If the video id isn't in the video_lockup, it should be in the info_rows
videoId = lookup.video_lockup?.endpoint.payload.videoId;
for (let i = 0; i < lookup.info_rows.length; i++) {
const info_row = lookup.info_rows[i];
if (info_row.info_row_expand_status_key === undefined) {
if (song === undefined) {
song = info_row.metadata_text;
song = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
if (videoId === undefined) {
videoId = info_row.metadata_endpoint?.payload;
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
videoId = endpoint?.payload?.videoId;
}
} else {
album = info_row.metadata_text;
album = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
}
} else {
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-artists-row-state-id') !== -1) {
artist = info_row.metadata_text;
artist = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
if (channelId === undefined) {
channelId = info_row.metadata_endpoint?.payload?.browseId;
const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;
channelId = endpoint?.payload?.browseId;
}
}
if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-licenses-row-state-id') !== -1) {
license = info_row.metadata_text;
license = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();
}
}
}

View File

@@ -373,10 +373,17 @@ class FormatUtils {
label: first_format.audio_track?.display_name as string
}, children);
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, track_objects[j], hoisted);
this.#hoistNumberAttributeIfPossible(set, track_objects[j], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, track_objects[j], hoisted);
period.appendChild(set);
for (const format of track_objects[j]) {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
} else {
@@ -387,43 +394,174 @@ class FormatUtils {
subsegmentAlignment: 'true'
});
const color_info = mime_objects[i][0].color_info;
if (typeof color_info !== 'undefined') {
// Section 5.5 Video source metadata signalling https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part7-v5.0.0.pdf
// Section 8 Video code points https://www.itu.int/rec/T-REC-H.273-202107-I/en
// The player.js file was also helpful
if (color_info.primaries) {
let primaries = '';
switch (color_info.primaries) {
case 'BT709':
primaries = '1';
break;
case 'BT2020':
primaries = '9';
break;
}
if (primaries !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:ColourPrimaries',
value: primaries
}));
}
}
if (color_info.transfer_characteristics) {
let transfer_characteristics = '';
switch (color_info.transfer_characteristics) {
case 'BT709':
transfer_characteristics = '1';
break;
case 'BT2020_10':
transfer_characteristics = '14';
break;
case 'SMPTEST2084':
transfer_characteristics = '16';
break;
case 'ARIB_STD_B67':
transfer_characteristics = '18';
break;
}
if (transfer_characteristics !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:TransferCharacteristics',
value: transfer_characteristics
}));
}
}
if (color_info.matrix_coefficients) {
let matrix_coefficients = '';
// This list is incomplete, as the player.js doesn't currently have any code for matrix coefficients,
// So it doesn't have a list like with the other two, so this is just based on what we've seen in responses
switch (color_info.matrix_coefficients) {
case 'BT709':
matrix_coefficients = '1';
break;
case 'BT2020_NCL':
matrix_coefficients = '14';
break;
default: {
const format = mime_objects[i][0];
const url = new URL(format.url as string);
const anonymisedFormat = JSON.parse(JSON.stringify(format));
anonymisedFormat.url = 'REDACTED';
anonymisedFormat.signature_cipher = 'REDACTED';
anonymisedFormat.cipher = 'REDACTED';
console.warn(`YouTube.js toDash(): Unknown matrix coefficients "${color_info.matrix_coefficients}", the DASH manifest is still usuable without this.\n`
+ `Please report it at ${Platform.shim.info.bugs_url} so we can add support for it.\n`
+ `Innertube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat);
}
}
if (matrix_coefficients !== '') {
set.appendChild(this.#el(document, 'EssentialProperty', {
schemeIdUri: 'urn:mpeg:mpegB:cicp:MatrixCoefficients',
value: matrix_coefficients
}));
}
}
}
const hoisted: string[] = [];
this.#hoistCodecsIfPossible(set, mime_objects[i], hoisted);
if (mime_objects[i][0].has_audio) {
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, mime_objects[i], hoisted);
} else {
set.setAttribute('maxPlayoutRate', '1');
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'frameRate', 'fps', hoisted);
}
period.appendChild(set);
for (const format of mime_objects[i]) {
if (format.has_video) {
await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions);
await this.#generateRepresentationVideo(document, set, format, url_transformer, hoisted, cpn, player, actions);
} else {
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions);
}
}
}
}
}
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1) {
set.setAttribute('codecs', getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"') as string);
hoisted.push('codecs');
}
}
static #hoistNumberAttributeIfPossible(set: Element, formats: Format[], attribute: 'audioSamplingRate' | 'frameRate', property: 'audio_sample_rate' | 'fps', hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) {
set.setAttribute(attribute, formats[0][property]?.toString() as string);
hoisted.push(attribute);
}
}
static #hoistAudioChannelsIfPossible(document: XMLDocument, set: Element, formats: Format[], hoisted: string[]) {
if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels?.toString() || '2')).size === 1) {
set.appendChild(
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: formats[0].audio_channels?.toString() || '2'
})
);
hoisted.push('AudioChannelConfiguration');
}
}
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const representation = this.#el(document, 'Representation', {
id: format.itag?.toString(),
codecs,
bandwidth: format.bitrate?.toString(),
width: format.width?.toString(),
height: format.height?.toString(),
maxPlayoutRate: '1',
frameRate: format.fps?.toString()
height: format.height?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('frameRate')) {
representation.setAttribute('frameRate', format.fps?.toString() as string);
}
set.appendChild(representation);
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
@@ -436,15 +574,26 @@ class FormatUtils {
const representation = this.#el(document, 'Representation', {
id,
codecs,
bandwidth: format.bitrate?.toString(),
audioSamplingRate: format.audio_sample_rate?.toString()
}, [
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: format.audio_channels?.toString() || '2'
})
]);
bandwidth: format.bitrate?.toString()
});
if (!hoisted.includes('codecs')) {
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
representation.setAttribute('codecs', codecs as string);
}
if (!hoisted.includes('audioSamplingRate')) {
representation.setAttribute('audioSamplingRate', format.audio_sample_rate?.toString() as string);
}
if (!hoisted.includes('AudioChannelConfiguration')) {
representation.appendChild(
this.#el(document, 'AudioChannelConfiguration', {
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
value: format.audio_channels?.toString() || '2'
})
);
}
set.appendChild(representation);

View File

@@ -321,7 +321,7 @@ describe('YouTube.js Tests', () => {
});
test('Innertube#music.getAlbum', async () => {
const album = await innertube.music.getAlbum('MPREb_aWzgoemOnfa');
const album = await innertube.music.getAlbum('MPREb_YpQ7SWMPLvu');
expect(album).toBeDefined();
expect(album.header).toBeDefined();
expect(album.sections).toBeDefined();