UNPKG

@antoniojps/youtubei.js

Version:

A JavaScript client for YouTube's private API, known as InnerTube.

233 lines 9.65 kB
// noinspection ES6MissingAwait import { EventEmitter } from '../../utils/index.js'; import { InnertubeError, Platform, u8ToBase64 } from '../../utils/Utils.js'; import { LiveChatContinuation, Parser } from '../index.js'; import SmoothedQueue from './SmoothedQueue.js'; import RunAttestationCommand from '../classes/commands/RunAttestationCommand.js'; import AddChatItemAction from '../classes/livechat/AddChatItemAction.js'; import UpdateDateTextAction from '../classes/livechat/UpdateDateTextAction.js'; import UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction.js'; import UpdateTitleAction from '../classes/livechat/UpdateTitleAction.js'; import UpdateToggleButtonTextAction from '../classes/livechat/UpdateToggleButtonTextAction.js'; import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction.js'; import NavigationEndpoint from '../classes/NavigationEndpoint.js'; import ItemMenu from './ItemMenu.js'; import { LiveMessageParams } from '../../../protos/generated/misc/params.js'; export default class LiveChat extends EventEmitter { #actions; #video_id; #channel_id; #continuation; #mcontinuation; #retry_count = 0; smoothed_queue; initial_info; metadata; running = false; is_replay = false; constructor(video_info) { super(); this.#video_id = video_info.basic_info.id; this.#channel_id = video_info.basic_info.channel_id; this.#actions = video_info.actions; this.#continuation = video_info.livechat?.continuation; this.is_replay = video_info.livechat?.is_replay || false; this.smoothed_queue = new SmoothedQueue(); this.smoothed_queue.callback = async (actions) => { if (!actions.length) { // Wait 2 seconds before requesting an incremental continuation if the action group is empty. await this.#wait(2000); } else if (actions.length < 10) { // If there are less than 10 actions, wait until all of them are emitted. await this.#emitSmoothedActions(actions); } else if (this.is_replay) { /** * NOTE: Live chat replays require data from the video player for actions to be emitted timely * and as we don't have that, this ends up being quite innacurate. */ this.#emitSmoothedActions(actions); await this.#wait(2000); } else { // There are more than 10 actions, emit them asynchronously so we can request the next incremental continuation. this.#emitSmoothedActions(actions); } if (this.running) { this.#pollLivechat(); } }; } on(type, listener) { super.on(type, listener); } once(type, listener) { super.once(type, listener); } start() { if (!this.running) { this.running = true; this.#pollLivechat(); this.#pollMetadata(); } } stop() { this.smoothed_queue.clear(); this.running = false; } #pollLivechat() { (async () => { try { const response = await this.#actions.execute(this.is_replay ? 'live_chat/get_live_chat_replay' : 'live_chat/get_live_chat', { continuation: this.#continuation, parse: true }); const contents = response.continuation_contents; if (!contents) { this.emit('error', new InnertubeError('Unexpected live chat incremental continuation response', response)); this.emit('end'); this.stop(); } if (!(contents instanceof LiveChatContinuation)) { this.stop(); this.emit('end'); return; } this.#continuation = contents.continuation.token; // Header only exists in the first request if (contents.header) { this.initial_info = contents; this.emit('start', contents); if (this.running) this.#pollLivechat(); } else { this.smoothed_queue.enqueueActionGroup(contents.actions); } this.#retry_count = 0; } catch (err) { this.emit('error', err); if (this.#retry_count++ < 10) { await this.#wait(2000); this.#pollLivechat(); } else { this.emit('error', new InnertubeError('Reached retry limit for incremental continuation requests', err)); this.emit('end'); this.stop(); } } })(); } /** * Ensures actions are emitted at the right speed. * This and {@link SmoothedQueue} were based off of YouTube's own implementation. */ async #emitSmoothedActions(action_queue) { const base = 1E4; let delay = action_queue.length < base / 80 ? 1 : Math.ceil(action_queue.length / (base / 80)); const emit_delay_ms = delay == 1 ? (delay = base / action_queue.length, delay *= Math.random() + 0.5, delay = Math.min(1E3, delay), delay = Math.max(80, delay)) : delay = 80; for (const action of action_queue) { await this.#wait(emit_delay_ms); this.emit('chat-update', action); } } #pollMetadata() { (async () => { try { const payload = { videoId: this.#video_id }; if (this.#mcontinuation) { payload.continuation = this.#mcontinuation; } const response = await this.#actions.execute('/updated_metadata', payload); const data = Parser.parseResponse(response.data); this.#mcontinuation = data.continuation?.token; this.metadata = { title: data.actions?.array().firstOfType(UpdateTitleAction) || this.metadata?.title, description: data.actions?.array().firstOfType(UpdateDescriptionAction) || this.metadata?.description, views: data.actions?.array().firstOfType(UpdateViewershipAction) || this.metadata?.views, likes: data.actions?.array().firstOfType(UpdateToggleButtonTextAction) || this.metadata?.likes, date: data.actions?.array().firstOfType(UpdateDateTextAction) || this.metadata?.date }; this.emit('metadata-update', this.metadata); await this.#wait(5000); if (this.running) this.#pollMetadata(); } catch { await this.#wait(2000); if (this.running) this.#pollMetadata(); } })(); } /** * Sends a message. * @param text - Text to send. */ async sendMessage(text) { const writer = LiveMessageParams.encode({ params: { ids: { videoId: this.#video_id, channelId: this.#channel_id } }, number0: 1, number1: 4 }); const params = btoa(encodeURIComponent(u8ToBase64(writer.finish()))); const response = await this.#actions.execute('/live_chat/send_message', { richMessage: { textSegments: [{ text }] }, clientMessageId: Platform.shim.uuidv4(), client: 'WEB', parse: true, params }); if (!response.actions) throw new InnertubeError('Unexpected response from send_message', response); return response.actions.array().as(AddChatItemAction, RunAttestationCommand); } /** * Applies given filter to the live chat. * @param filter - Filter to apply. */ applyFilter(filter) { if (!this.initial_info) throw new InnertubeError('Cannot apply filter before initial info is retrieved.'); const menu_items = this.initial_info?.header?.view_selector?.sub_menu_items; if (filter === 'TOP_CHAT') { if (menu_items?.at(0)?.selected) return; this.#continuation = menu_items?.at(0)?.continuation; } else { if (menu_items?.at(1)?.selected) return; this.#continuation = menu_items?.at(1)?.continuation; } } /** * Retrieves given chat item's menu. */ async getItemMenu(item) { if (!item.hasKey('menu_endpoint') || !item.key('menu_endpoint').isInstanceof(NavigationEndpoint)) throw new InnertubeError('This item does not have a menu.', item); const response = await item.key('menu_endpoint').instanceof(NavigationEndpoint).call(this.#actions, { parse: true }); if (!response) throw new InnertubeError('Could not retrieve item menu.', item); return new ItemMenu(response, this.#actions); } /** * Equivalent to "clicking" a button. */ async selectButton(button) { return await button.endpoint.call(this.#actions, { parse: true }); } async #wait(ms) { return new Promise((resolve) => setTimeout(() => resolve(), ms)); } } //# sourceMappingURL=LiveChat.js.map