diff --git a/README.md b/README.md index cf7b2108..daddbb69 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,6 @@ const yt = await Innertube.create({ * [.getPlaylist(id)](#getplaylist) * [.getHashtag(hashtag)](#gethashtag) * [.getStreamingData(video_id, options)](#getstreamingdata) - * [.getTranscript(video_id)](#gettranscript) * [.download(video_id, options?)](#download) * [.resolveURL(url)](#resolveurl) * [.call(endpoint, args?)](#call) @@ -326,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. @@ -661,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/examples/transcript/index.ts b/examples/transcript/index.ts new file mode 100644 index 00000000..bb4a9e39 --- /dev/null +++ b/examples/transcript/index.ts @@ -0,0 +1,16 @@ +import { Innertube } from 'youtubei.js'; + +(async () => { + const yt = await Innertube.create({ generate_session_locally: true }); + + const info = await yt.getInfo('hePb00CqvP0'); + + const defaultTranscriptInfo = await info.getTranscript(); + + console.log(`Got ${defaultTranscriptInfo.selectedLanguage} transcript with ${defaultTranscriptInfo.transcript.content.body.initial_segments.length} lines.`); + + console.log("Fetching Hebrew transcript..."); + + const heTranscriptInfo = await defaultTranscriptInfo.selectLanguage('Hebrew'); + console.log(`Got ${heTranscriptInfo.selectedLanguage} transcript with ${heTranscriptInfo.transcript.content.body.initial_segments.length} lines.`); +})(); \ No newline at end of file diff --git a/src/Innertube.ts b/src/Innertube.ts index ce6c5257..2252fd00 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -14,8 +14,6 @@ import NotificationsMenu from './parser/youtube/NotificationsMenu.js'; import Playlist from './parser/youtube/Playlist.js'; import Search from './parser/youtube/Search.js'; import VideoInfo from './parser/youtube/VideoInfo.js'; -import ContinuationItem from './parser/classes/ContinuationItem.js'; -import Transcript from './parser/classes/Transcript.js'; import { Kids, Music, Studio } from './core/clients/index.js'; import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js'; @@ -38,7 +36,7 @@ import { import { GetUnseenCountEndpoint } from './core/endpoints/notification/index.js'; import type { ApiResponse } from './core/Actions.js'; -import { type IGetTranscriptResponse, type IBrowseResponse, type IParsedResponse } from './parser/types/index.js'; +import { type IBrowseResponse, type IParsedResponse } from './parser/types/index.js'; import type { INextRequest } from './types/index.js'; import type { DownloadOptions, FormatOptions } from './types/FormatUtils.js'; @@ -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/src/core/mixins/MediaInfo.ts b/src/core/mixins/MediaInfo.ts index 3d0d5bd1..bb66e935 100644 --- a/src/core/mixins/MediaInfo.ts +++ b/src/core/mixins/MediaInfo.ts @@ -10,6 +10,8 @@ import Parser from '../../parser/index.js'; import type { DashOptions } from '../../types/DashOptions.js'; import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js'; import { getStreamingInfo } from '../../utils/StreamingInfo.js'; +import ContinuationItem from '../../parser/classes/ContinuationItem.js'; +import TranscriptInfo from '../../parser/youtube/TranscriptInfo.js'; 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/src/parser/classes/StructuredDescriptionContent.ts b/src/parser/classes/StructuredDescriptionContent.ts index 3936e067..382d6b83 100644 --- a/src/parser/classes/StructuredDescriptionContent.ts +++ b/src/parser/classes/StructuredDescriptionContent.ts @@ -5,18 +5,24 @@ import HorizontalCardList from './HorizontalCardList.js'; import VideoDescriptionHeader from './VideoDescriptionHeader.js'; import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.js'; import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js'; -import type VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.js'; +import VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.js'; +import VideoDescriptionCourseSection from './VideoDescriptionCourseSection.js'; 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/src/parser/classes/StructuredDescriptionPlaylistLockup.ts b/src/parser/classes/StructuredDescriptionPlaylistLockup.ts new file mode 100644 index 00000000..2683203d --- /dev/null +++ b/src/parser/classes/StructuredDescriptionPlaylistLockup.ts @@ -0,0 +1,34 @@ +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; +import Text from './misc/Text.js'; +import Thumbnail from './misc/Thumbnail.js'; + +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/src/parser/classes/TranscriptSectionHeader.ts b/src/parser/classes/TranscriptSectionHeader.ts new file mode 100644 index 00000000..e9d8585a --- /dev/null +++ b/src/parser/classes/TranscriptSectionHeader.ts @@ -0,0 +1,18 @@ +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import Text from './misc/Text.js'; + +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/src/parser/classes/TranscriptSegmentList.ts b/src/parser/classes/TranscriptSegmentList.ts index da7090f9..cb200a36 100644 --- a/src/parser/classes/TranscriptSegmentList.ts +++ b/src/parser/classes/TranscriptSegmentList.ts @@ -1,21 +1,22 @@ -import type { ObservedArray} from '../helpers.js'; +import type { ObservedArray } from '../helpers.js'; import { YTNode } from '../helpers.js'; import type { RawNode } from '../index.js'; import Parser from '../index.js'; import { Text } from '../misc.js'; +import TranscriptSectionHeader from './TranscriptSectionHeader.js'; import TranscriptSegment from './TranscriptSegment.js'; 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/src/parser/classes/VideoDescriptionCourseSection.ts b/src/parser/classes/VideoDescriptionCourseSection.ts new file mode 100644 index 00000000..4b6b1db1 --- /dev/null +++ b/src/parser/classes/VideoDescriptionCourseSection.ts @@ -0,0 +1,19 @@ +import type { ObservedArray} from '../helpers.js'; +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import Parser from '../index.js'; +import StructuredDescriptionPlaylistLockup from './StructuredDescriptionPlaylistLockup.js'; +import Text from './misc/Text.js'; + +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/src/parser/nodes.ts b/src/parser/nodes.ts index c1cc8331..0ac65c62 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -328,6 +328,7 @@ export { default as SlimOwner } from './classes/SlimOwner.js'; export { default as SlimVideoMetadata } from './classes/SlimVideoMetadata.js'; export { default as SortFilterSubMenu } from './classes/SortFilterSubMenu.js'; export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.js'; +export { default as StructuredDescriptionPlaylistLockup } from './classes/StructuredDescriptionPlaylistLockup.js'; export { default as SubFeedOption } from './classes/SubFeedOption.js'; export { default as SubFeedSelector } from './classes/SubFeedSelector.js'; export { default as SubscribeButton } from './classes/SubscribeButton.js'; @@ -359,6 +360,7 @@ export { default as Transcript } from './classes/Transcript.js'; export { default as TranscriptFooter } from './classes/TranscriptFooter.js'; export { default as TranscriptSearchBox } from './classes/TranscriptSearchBox.js'; export { default as TranscriptSearchPanel } from './classes/TranscriptSearchPanel.js'; +export { default as TranscriptSectionHeader } from './classes/TranscriptSectionHeader.js'; export { default as TranscriptSegment } from './classes/TranscriptSegment.js'; export { default as TranscriptSegmentList } from './classes/TranscriptSegmentList.js'; export { default as TwoColumnBrowseResults } from './classes/TwoColumnBrowseResults.js'; @@ -371,6 +373,7 @@ export { default as VerticalList } from './classes/VerticalList.js'; export { default as VerticalWatchCardList } from './classes/VerticalWatchCardList.js'; export { default as Video } from './classes/Video.js'; export { default as VideoCard } from './classes/VideoCard.js'; +export { default as VideoDescriptionCourseSection } from './classes/VideoDescriptionCourseSection.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'; diff --git a/src/parser/youtube/TranscriptInfo.ts b/src/parser/youtube/TranscriptInfo.ts new file mode 100644 index 00000000..ec7657f4 --- /dev/null +++ b/src/parser/youtube/TranscriptInfo.ts @@ -0,0 +1,55 @@ +import type Actions from '../../core/Actions.js'; +import { type ApiResponse } from '../../core/Actions.js'; +import type { IGetTranscriptResponse } from '../index.js'; +import Parser from '../index.js'; +import Transcript from '../classes/Transcript.js'; + +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