refactor: Move transcript logic to MediaInfo (#511)

* refactor: Move transcript logic to `MediaInfo`

+ Add support for retrieving different languages.

* docs: Update and add examples
This commit is contained in:
Luan
2023-09-17 22:17:14 -03:00
committed by GitHub
parent d2959b3a55
commit 69702085c6
11 changed files with 194 additions and 49 deletions

View File

@@ -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.
- `<info>#download(options)`
- Downloads the video. See [download](#download).
- `<info>#getTranscript()`
- Retrieves the video's transcript.
- `<info>#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 |
<a name="gettranscript"></a>
### `getTranscript(video_id)`
Retrieves a given video's transcript.
**Returns**: `Promise<Transcript>`
| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
<a name="download"></a>
### `download(video_id, options?)`
Downloads a given video.

View File

@@ -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.`);
})();

View File

@@ -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<Transcript> {
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<IGetTranscriptResponse>(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}.

View File

@@ -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<TranscriptInfo> {
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.
*/

View File

@@ -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
]);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<TranscriptSegment>;
initial_segments: ObservedArray<TranscriptSegment | TranscriptSectionHeader>;
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;

View File

@@ -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<StructuredDescriptionPlaylistLockup>;
constructor(data: RawNode) {
super();
this.section_title = new Text(data.sectionTitle);
this.media_lockups = Parser.parseArray(data.mediaLockups, [ StructuredDescriptionPlaylistLockup ]);
}
}

View File

@@ -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';

View File

@@ -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<TranscriptInfo> {
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;
}
}