diff --git a/README.md b/README.md index 79a24427..daddbb69 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,9 @@ Retrieves video info. - `#download(options)` - Downloads the video. See [download](#download). +- `#getTranscript()` + - Retrieves the video's transcript. + - `#filters` - Returns filters that can be applied to the watch next feed. @@ -660,16 +663,6 @@ console.info('Playback url:', url); | video_id | `string` | Video id | | options | `FormatOptions` | Format options | - -### `getTranscript(video_id)` -Retrieves a given video's transcript. - -**Returns**: `Promise` - -| Param | Type | Description | -| --- | --- | --- | -| video_id | `string` | Video id | - ### `download(video_id, options?)` Downloads a given video. diff --git a/deno/package.json b/deno/package.json index 2d830fae..98d7d988 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "6.4.0", + "version": "6.4.1", "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", diff --git a/deno/src/Innertube.ts b/deno/src/Innertube.ts index ebc9608b..55927c48 100644 --- a/deno/src/Innertube.ts +++ b/deno/src/Innertube.ts @@ -14,8 +14,6 @@ import NotificationsMenu from './parser/youtube/NotificationsMenu.ts'; import Playlist from './parser/youtube/Playlist.ts'; import Search from './parser/youtube/Search.ts'; import VideoInfo from './parser/youtube/VideoInfo.ts'; -import ContinuationItem from './parser/classes/ContinuationItem.ts'; -import Transcript from './parser/classes/Transcript.ts'; import { Kids, Music, Studio } from './core/clients/index.ts'; import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.ts'; @@ -38,7 +36,7 @@ import { import { GetUnseenCountEndpoint } from './core/endpoints/notification/index.ts'; import type { ApiResponse } from './core/Actions.ts'; -import { type IGetTranscriptResponse, type IBrowseResponse, type IParsedResponse } from './parser/types/index.ts'; +import { type IBrowseResponse, type IParsedResponse } from './parser/types/index.ts'; import type { INextRequest } from './types/index.ts'; import type { DownloadOptions, FormatOptions } from './types/FormatUtils.ts'; @@ -334,35 +332,6 @@ export default class Innertube { return info.chooseFormat(options); } - /** - * Retrieves a video's transcript. - * @param video_id - The video id. - */ - async getTranscript(video_id: string): Promise { - throwIfMissing({ video_id }); - - const next_response = await this.actions.execute(NextEndpoint.PATH, { ...NextEndpoint.build({ video_id }), parse: true }); - - if (!next_response.engagement_panels) - throw new InnertubeError('Engagement panels not found. Video likely has no transcript.'); - - const transcript_panel = next_response.engagement_panels.get({ - panel_identifier: 'engagement-panel-searchable-transcript' - }); - - if (!transcript_panel) - throw new InnertubeError('Transcript panel not found. Video likely has no transcript.'); - - const transcript_continuation = transcript_panel.content?.as(ContinuationItem); - - if (!transcript_continuation) - throw new InnertubeError('Transcript continuation not found.'); - - const transcript_response = await transcript_continuation.endpoint.call(this.actions, { parse: true }); - - return transcript_response.actions_memo.getType(Transcript).first(); - } - /** * Downloads a given video. If you only need the direct download link see {@link getStreamingData}. * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}. diff --git a/deno/src/core/Player.ts b/deno/src/core/Player.ts index 7a4cfc8f..9dfad9a0 100644 --- a/deno/src/core/Player.ts +++ b/deno/src/core/Player.ts @@ -10,6 +10,7 @@ import type { FetchFunction } from '../types/PlatformShim.ts'; */ export default class Player { #nsig_sc; + #nsig_cache; #sig_sc; #sig_sc_timestamp; #player_id; @@ -21,6 +22,8 @@ export default class Player { this.#sig_sc_timestamp = signature_timestamp; this.#player_id = player_id; + + this.#nsig_cache = new Map(); } static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch): Promise { @@ -66,7 +69,7 @@ export default class Player { return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id); } - decipher(url?: string, signature_cipher?: string, cipher?: string): string { + decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map): string { url = url || signature_cipher || cipher; if (!url) @@ -93,15 +96,23 @@ export default class Player { const n = url_components.searchParams.get('n'); if (n) { - const nsig = Platform.shim.eval(this.#nsig_sc, { - nsig: n - }); + let nsig; - if (typeof nsig !== 'string') - throw new PlayerError('Failed to decipher nsig'); + if (this_response_nsig_cache && this_response_nsig_cache.has(n)) { + nsig = this_response_nsig_cache.get(n) as string; + } else { + nsig = Platform.shim.eval(this.#nsig_sc, { + nsig: n + }); - if (nsig.startsWith('enhanced_except_')) { - console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!'); + if (typeof nsig !== 'string') + throw new PlayerError('Failed to decipher nsig'); + + if (nsig.startsWith('enhanced_except_')) { + console.warn('Warning:\nCould not transform nsig, download may be throttled.\nChanging the InnerTube client to "ANDROID" might help!'); + } else if (this_response_nsig_cache) { + this_response_nsig_cache.set(n, nsig); + } } url_components.searchParams.set('n', nsig); diff --git a/deno/src/core/mixins/Feed.ts b/deno/src/core/mixins/Feed.ts index 0e5a1210..e7cee5ea 100644 --- a/deno/src/core/mixins/Feed.ts +++ b/deno/src/core/mixins/Feed.ts @@ -185,10 +185,8 @@ export default class Feed { */ async getContinuationData(): Promise { if (this.#continuation) { - if (this.#continuation.length > 1) - throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page'); if (this.#continuation.length === 0) - throw new InnertubeError('There are no continuations'); + throw new InnertubeError('There are no continuations.'); const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true }); diff --git a/deno/src/core/mixins/MediaInfo.ts b/deno/src/core/mixins/MediaInfo.ts index ed7622ec..63001406 100644 --- a/deno/src/core/mixins/MediaInfo.ts +++ b/deno/src/core/mixins/MediaInfo.ts @@ -10,6 +10,8 @@ import Parser from '../../parser/index.ts'; import type { DashOptions } from '../../types/DashOptions.ts'; import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.ts'; import { getStreamingInfo } from '../../utils/StreamingInfo.ts'; +import ContinuationItem from '../../parser/classes/ContinuationItem.ts'; +import TranscriptInfo from '../../parser/youtube/TranscriptInfo.ts'; export default class MediaInfo { #page: [IPlayerResponse, INextResponse?]; @@ -84,6 +86,36 @@ export default class MediaInfo { return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn); } + /** + * Retrieves the video's transcript. + * @param video_id - The video id. + */ + async getTranscript(): Promise { + const next_response = this.page[1]; + + if (!next_response) + throw new InnertubeError('Cannot get transcript from basic video info.'); + + if (!next_response.engagement_panels) + throw new InnertubeError('Engagement panels not found. Video likely has no transcript.'); + + const transcript_panel = next_response.engagement_panels.get({ + panel_identifier: 'engagement-panel-searchable-transcript' + }); + + if (!transcript_panel) + throw new InnertubeError('Transcript panel not found. Video likely has no transcript.'); + + const transcript_continuation = transcript_panel.content?.as(ContinuationItem); + + if (!transcript_continuation) + throw new InnertubeError('Transcript continuation not found.'); + + const response = await transcript_continuation.endpoint.call(this.actions); + + return new TranscriptInfo(this.actions, response); + } + /** * Adds video to the watch history. */ diff --git a/deno/src/parser/classes/StructuredDescriptionContent.ts b/deno/src/parser/classes/StructuredDescriptionContent.ts index 494dde02..b03d2da2 100644 --- a/deno/src/parser/classes/StructuredDescriptionContent.ts +++ b/deno/src/parser/classes/StructuredDescriptionContent.ts @@ -5,18 +5,24 @@ import HorizontalCardList from './HorizontalCardList.ts'; import VideoDescriptionHeader from './VideoDescriptionHeader.ts'; import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.ts'; import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.ts'; -import type VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.ts'; +import VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.ts'; +import VideoDescriptionCourseSection from './VideoDescriptionCourseSection.ts'; export default class StructuredDescriptionContent extends YTNode { static type = 'StructuredDescriptionContent'; items: ObservedArray< VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | - VideoDescriptionInfocardsSection | VideoDescriptionTranscriptSection | HorizontalCardList + VideoDescriptionInfocardsSection | VideoDescriptionTranscriptSection | VideoDescriptionTranscriptSection | + VideoDescriptionCourseSection | HorizontalCardList >; constructor(data: RawNode) { super(); - this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection, HorizontalCardList ]); + this.items = Parser.parseArray(data.items, [ + VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, + VideoDescriptionInfocardsSection, VideoDescriptionCourseSection, VideoDescriptionTranscriptSection, + VideoDescriptionTranscriptSection, HorizontalCardList + ]); } } \ No newline at end of file diff --git a/deno/src/parser/classes/StructuredDescriptionPlaylistLockup.ts b/deno/src/parser/classes/StructuredDescriptionPlaylistLockup.ts new file mode 100644 index 00000000..ae5b8fa7 --- /dev/null +++ b/deno/src/parser/classes/StructuredDescriptionPlaylistLockup.ts @@ -0,0 +1,34 @@ +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import NavigationEndpoint from './NavigationEndpoint.ts'; +import Text from './misc/Text.ts'; +import Thumbnail from './misc/Thumbnail.ts'; + +export default class StructuredDescriptionPlaylistLockup extends YTNode { + static type = 'StructuredDescriptionPlaylistLockup'; + + thumbnail: Thumbnail[]; + title: Text; + short_byline_text: Text; + video_count_short_text: Text; + endpoint: NavigationEndpoint; + thumbnail_width: number; + aspect_ratio: number; + max_lines_title: number; + max_lines_short_byline_text: number; + overlay_position: string; + + constructor(data: RawNode) { + super(); + this.thumbnail = Thumbnail.fromResponse(data.thumbnail); + this.title = new Text(data.title); + this.short_byline_text = new Text(data.shortBylineText); + this.video_count_short_text = new Text(data.videoCountShortText); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.thumbnail_width = data.thumbnailWidth; + this.aspect_ratio = data.aspectRatio; + this.max_lines_title = data.maxLinesTitle; + this.max_lines_short_byline_text = data.maxLinesShortBylineText; + this.overlay_position = data.overlayPosition; + } +} diff --git a/deno/src/parser/classes/TranscriptSectionHeader.ts b/deno/src/parser/classes/TranscriptSectionHeader.ts new file mode 100644 index 00000000..67c53185 --- /dev/null +++ b/deno/src/parser/classes/TranscriptSectionHeader.ts @@ -0,0 +1,18 @@ +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import Text from './misc/Text.ts'; + +export default class TranscriptSectionHeader extends YTNode { + static type = 'TranscriptSectionHeader'; + + start_ms: string; + end_ms: string; + snippet: Text; + + constructor(data: RawNode) { + super(); + this.start_ms = data.startMs; + this.end_ms = data.endMs; + this.snippet = new Text(data.snippet); + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/TranscriptSegmentList.ts b/deno/src/parser/classes/TranscriptSegmentList.ts index 6f0c0fd2..050f98ba 100644 --- a/deno/src/parser/classes/TranscriptSegmentList.ts +++ b/deno/src/parser/classes/TranscriptSegmentList.ts @@ -1,21 +1,22 @@ -import type { ObservedArray} from '../helpers.ts'; +import type { ObservedArray } from '../helpers.ts'; import { YTNode } from '../helpers.ts'; import type { RawNode } from '../index.ts'; import Parser from '../index.ts'; import { Text } from '../misc.ts'; +import TranscriptSectionHeader from './TranscriptSectionHeader.ts'; import TranscriptSegment from './TranscriptSegment.ts'; export default class TranscriptSegmentList extends YTNode { static type = 'TranscriptSegmentList'; - initial_segments: ObservedArray; + initial_segments: ObservedArray; no_result_label: Text; retry_label: Text; touch_captions_enabled: boolean; constructor(data: RawNode) { super(); - this.initial_segments = Parser.parseArray(data.initialSegments, TranscriptSegment); + this.initial_segments = Parser.parseArray(data.initialSegments, [ TranscriptSegment, TranscriptSectionHeader ]); this.no_result_label = new Text(data.noResultLabel); this.retry_label = new Text(data.retryLabel); this.touch_captions_enabled = data.touchCaptionsEnabled; diff --git a/deno/src/parser/classes/VideoDescriptionCourseSection.ts b/deno/src/parser/classes/VideoDescriptionCourseSection.ts new file mode 100644 index 00000000..afb67aa1 --- /dev/null +++ b/deno/src/parser/classes/VideoDescriptionCourseSection.ts @@ -0,0 +1,19 @@ +import type { ObservedArray} from '../helpers.ts'; +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import Parser from '../index.ts'; +import StructuredDescriptionPlaylistLockup from './StructuredDescriptionPlaylistLockup.ts'; +import Text from './misc/Text.ts'; + +export default class VideoDescriptionCourseSection extends YTNode { + static type = 'VideoDescriptionCourseSection'; + + section_title: Text; + media_lockups: ObservedArray; + + constructor(data: RawNode) { + super(); + this.section_title = new Text(data.sectionTitle); + this.media_lockups = Parser.parseArray(data.mediaLockups, [ StructuredDescriptionPlaylistLockup ]); + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/misc/Format.ts b/deno/src/parser/classes/misc/Format.ts index 2e51eb62..4f2df35d 100644 --- a/deno/src/parser/classes/misc/Format.ts +++ b/deno/src/parser/classes/misc/Format.ts @@ -3,6 +3,8 @@ import { InnertubeError } from '../../../utils/Utils.ts'; import type { RawNode } from '../../index.ts'; export default class Format { + #this_response_nsig_cache?: Map; + itag: number; mime_type: string; is_type_otf: boolean; @@ -52,7 +54,11 @@ export default class Format { matrix_coefficients?: string; }; - constructor(data: RawNode) { + constructor(data: RawNode, this_response_nsig_cache?: Map) { + if (this_response_nsig_cache) { + this.#this_response_nsig_cache = this_response_nsig_cache; + } + this.itag = data.itag; this.mime_type = data.mimeType; this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF'; @@ -122,6 +128,6 @@ export default class Format { */ decipher(player: Player | undefined): string { if (!player) throw new InnertubeError('Cannot decipher format, this session appears to have no valid player.'); - return player.decipher(this.url, this.signature_cipher, this.cipher); + return player.decipher(this.url, this.signature_cipher, this.cipher, this.#this_response_nsig_cache); } } \ No newline at end of file diff --git a/deno/src/parser/generator.ts b/deno/src/parser/generator.ts index 8ceec39c..7d28f9b0 100644 --- a/deno/src/parser/generator.ts +++ b/deno/src/parser/generator.ts @@ -134,33 +134,35 @@ export function isRendererList(value: unknown) { * @returns If it is a misc type, return the InferenceType. Otherwise, return false. */ export function isMiscType(key: string, value: unknown): MiscInferenceType | false { - // NavigationEndpoint - if ((key.endsWith('Endpoint') || key.endsWith('Command') || key === 'endpoint') && typeof value === 'object' && value !== null) { - return { - type: 'misc', - endpoint: new NavigationEndpoint(value), - optional: false, - misc_type: 'NavigationEndpoint' - }; - } - // Text - if (typeof value === 'object' && value !== null && (Reflect.has(value, 'simpleText') || Reflect.has(value, 'runs'))) { - const textNode = new Text(value); - return { - type: 'misc', - misc_type: 'Text', - optional: false, - endpoint: textNode.endpoint, - text: textNode.toString() - }; - } - // Thumbnail - if (typeof value === 'object' && value !== null && Reflect.has(value, 'thumbnails') && Array.isArray(Reflect.get(value, 'thumbnails'))) { - return { - type: 'misc', - misc_type: 'Thumbnail', - optional: false - }; + if (typeof value === 'object' && value !== null) { + // NavigationEndpoint + if (key.endsWith('Endpoint') || key.endsWith('Command') || key === 'endpoint') { + return { + type: 'misc', + endpoint: new NavigationEndpoint(value), + optional: false, + misc_type: 'NavigationEndpoint' + }; + } + // Text + if (Reflect.has(value, 'simpleText') || Reflect.has(value, 'runs')) { + const textNode = new Text(value); + return { + type: 'misc', + misc_type: 'Text', + optional: false, + endpoint: textNode.endpoint, + text: textNode.toString() + }; + } + // Thumbnail + if (Reflect.has(value, 'thumbnails') && Array.isArray(Reflect.get(value, 'thumbnails'))) { + return { + type: 'misc', + misc_type: 'Thumbnail', + optional: false + }; + } } return false; } diff --git a/deno/src/parser/nodes.ts b/deno/src/parser/nodes.ts index 3d491634..47aeda90 100644 --- a/deno/src/parser/nodes.ts +++ b/deno/src/parser/nodes.ts @@ -328,6 +328,7 @@ export { default as SlimOwner } from './classes/SlimOwner.ts'; export { default as SlimVideoMetadata } from './classes/SlimVideoMetadata.ts'; export { default as SortFilterSubMenu } from './classes/SortFilterSubMenu.ts'; export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.ts'; +export { default as StructuredDescriptionPlaylistLockup } from './classes/StructuredDescriptionPlaylistLockup.ts'; export { default as SubFeedOption } from './classes/SubFeedOption.ts'; export { default as SubFeedSelector } from './classes/SubFeedSelector.ts'; export { default as SubscribeButton } from './classes/SubscribeButton.ts'; @@ -359,6 +360,7 @@ export { default as Transcript } from './classes/Transcript.ts'; export { default as TranscriptFooter } from './classes/TranscriptFooter.ts'; export { default as TranscriptSearchBox } from './classes/TranscriptSearchBox.ts'; export { default as TranscriptSearchPanel } from './classes/TranscriptSearchPanel.ts'; +export { default as TranscriptSectionHeader } from './classes/TranscriptSectionHeader.ts'; export { default as TranscriptSegment } from './classes/TranscriptSegment.ts'; export { default as TranscriptSegmentList } from './classes/TranscriptSegmentList.ts'; export { default as TwoColumnBrowseResults } from './classes/TwoColumnBrowseResults.ts'; @@ -371,6 +373,7 @@ export { default as VerticalList } from './classes/VerticalList.ts'; export { default as VerticalWatchCardList } from './classes/VerticalWatchCardList.ts'; export { default as Video } from './classes/Video.ts'; export { default as VideoCard } from './classes/VideoCard.ts'; +export { default as VideoDescriptionCourseSection } from './classes/VideoDescriptionCourseSection.ts'; export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.ts'; export { default as VideoDescriptionInfocardsSection } from './classes/VideoDescriptionInfocardsSection.ts'; export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.ts'; diff --git a/deno/src/parser/parser.ts b/deno/src/parser/parser.ts index 1b22a066..572383cc 100644 --- a/deno/src/parser/parser.ts +++ b/deno/src/parser/parser.ts @@ -367,15 +367,21 @@ export function parseResponse(data: parsed_data.playability_status = playability_status; } - const streaming_data = data.streamingData ? { - expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000), - formats: parseFormats(data.streamingData.formats), - adaptive_formats: parseFormats(data.streamingData.adaptiveFormats), - dash_manifest_url: data.streamingData.dashManifestUrl || null, - hls_manifest_url: data.streamingData.hlsManifestUrl || null - } : undefined; + if (data.streamingData) { + // Currently each response with streaming data only has two n param values + // One for the adaptive formats and another for the combined formats + // As they are the same for a response, we only need to decipher them once + // For all futher deciphering calls on formats from that response, we can use the cached output, given the same input n param + const this_response_nsig_cache = new Map(); + + const streaming_data = { + expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000), + formats: parseFormats(data.streamingData.formats, this_response_nsig_cache), + adaptive_formats: parseFormats(data.streamingData.adaptiveFormats, this_response_nsig_cache), + dash_manifest_url: data.streamingData.dashManifestUrl || null, + hls_manifest_url: data.streamingData.hlsManifestUrl || null + }; - if (streaming_data) { parsed_data.streaming_data = streaming_data; } @@ -598,8 +604,8 @@ export function parseActions(data: RawData) { return new SuperParsedResult(parseItem(data)); } -export function parseFormats(formats: RawNode[]): Format[] { - return formats?.map((format) => new Format(format)) || []; +export function parseFormats(formats: RawNode[], this_response_nsig_cache: Map): Format[] { + return formats?.map((format) => new Format(format, this_response_nsig_cache)) || []; } export function applyMutations(memo: Memo, mutations: RawNode[]) { diff --git a/deno/src/parser/youtube/Playlist.ts b/deno/src/parser/youtube/Playlist.ts index 1da3da2b..024f518e 100644 --- a/deno/src/parser/youtube/Playlist.ts +++ b/deno/src/parser/youtube/Playlist.ts @@ -9,6 +9,7 @@ import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo.ts import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo.ts'; import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail.ts'; import VideoOwner from '../classes/VideoOwner.ts'; +import Alert from '../classes/Alert.ts'; import { InnertubeError } from '../../utils/Utils.ts'; import type { ObservedArray } from '../helpers.ts'; @@ -17,7 +18,7 @@ import type Actions from '../../core/Actions.ts'; import type { ApiResponse } from '../../core/Actions.ts'; import type { IBrowseResponse } from '../types/ParsedResponse.ts'; -class Playlist extends Feed { +export default class Playlist extends Feed { info; menu; endpoint?: NavigationEndpoint; @@ -29,9 +30,13 @@ class Playlist extends Feed { const header = this.memo.getType(PlaylistHeader).first(); const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo).first(); const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo).first(); + const alert = this.page.alerts?.firstOfType(Alert); - if (!primary_info && !secondary_info) - throw new InnertubeError('This playlist does not exist'); + if (alert && alert.alert_type === 'ERROR') + throw new InnertubeError(alert.text.toString(), alert); + + if (!primary_info && !secondary_info && Object.keys(this.page).length === 0) + throw new InnertubeError('Got empty continuation response. This is likely the end of the playlist.'); this.info = { ...this.page.metadata?.item().as(PlaylistMetadata), @@ -69,6 +74,4 @@ class Playlist extends Feed { throw new InnertubeError('Could not get continuation data'); return new Playlist(this.actions, page, true); } -} - -export default Playlist; \ No newline at end of file +} \ No newline at end of file diff --git a/deno/src/parser/youtube/TranscriptInfo.ts b/deno/src/parser/youtube/TranscriptInfo.ts new file mode 100644 index 00000000..a9054e6a --- /dev/null +++ b/deno/src/parser/youtube/TranscriptInfo.ts @@ -0,0 +1,55 @@ +import type Actions from '../../core/Actions.ts'; +import { type ApiResponse } from '../../core/Actions.ts'; +import type { IGetTranscriptResponse } from '../index.ts'; +import Parser from '../index.ts'; +import Transcript from '../classes/Transcript.ts'; + +export default class TranscriptInfo { + #page: IGetTranscriptResponse; + #actions: Actions; + transcript: Transcript; + + constructor(actions: Actions, response: ApiResponse) { + this.#page = Parser.parseResponse(response.data); + this.#actions = actions; + this.transcript = this.#page.actions_memo.getType(Transcript).first(); + } + + /** + * Selects a language from the language menu and returns the updated transcript. + * @param language - Language to select. + */ + async selectLanguage(language: string): Promise { + const target_menu_item = this.transcript.content?.footer?.language_menu?.sub_menu_items?.find((item) => item.title.toString() === language); + + if (!target_menu_item) + throw new Error(`Language not found: ${language}`); + + if (target_menu_item.selected) + return this; + + const response = await this.#actions.execute('/get_transcript', { + params: target_menu_item.continuation + }); + + return new TranscriptInfo(this.#actions, response); + } + + /** + * Returns available languages. + */ + get languages(): string[] { + return this.transcript.content?.footer?.language_menu?.sub_menu_items?.map((item) => item.title.toString()) || []; + } + + /** + * Returns the currently selected language. + */ + get selectedLanguage(): string { + return this.transcript.content?.footer?.language_menu?.sub_menu_items?.find((item) => item.selected)?.title.toString() || ''; + } + + get page() { + return this.#page; + } +} \ No newline at end of file