From ab9518593dea45e316894624aaae4bc94741ee21 Mon Sep 17 00:00:00 2001 From: "Carson M." Date: Fri, 26 Jan 2024 11:30:29 -0600 Subject: [PATCH] initial youtube live chat support --- Cargo.toml | 25 ++- README.md | 2 +- examples/{main.rs => twitch.rs} | 6 +- examples/youtube.rs | 12 ++ src/lib.rs | 83 +------- src/{ => twitch}/event.rs | 0 src/{ => twitch}/identity.rs | 0 src/twitch/mod.rs | 79 ++++++++ src/youtube/mod.rs | 323 ++++++++++++++++++++++++++++++++ src/youtube/types.rs | 319 +++++++++++++++++++++++++++++++ src/youtube/util.rs | 29 +++ 11 files changed, 789 insertions(+), 89 deletions(-) rename examples/{main.rs => twitch.rs} (55%) create mode 100644 examples/youtube.rs rename src/{ => twitch}/event.rs (100%) rename src/{ => twitch}/identity.rs (100%) create mode 100644 src/twitch/mod.rs create mode 100644 src/youtube/mod.rs create mode 100644 src/youtube/types.rs create mode 100644 src/youtube/util.rs diff --git a/Cargo.toml b/Cargo.toml index a867f96..d446949 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,33 @@ [package] name = "brainrot" +description = "A live chat interface for Twitch & YouTube" version = "0.1.0" +authors = [ "Carson M. " ] +repository = "https://github.com/vitri-ent/brainrot" edition = "2021" -rust-version = "1.64" +rust-version = "1.75" [dependencies] -irc = { version = "0.15", default-features = false } -tokio = { version = "1", features = [ "net" ] } +irc = { version = "0.15", optional = true, default-features = false } +tokio = { version = "1.0", default-features = false, features = [ "net" ] } futures-util = { version = "0.3", default-features = false } thiserror = "1.0" chrono = { version = "0.4", default-features = false, features = [ "clock", "std" ] } serde = { version = "1.0", optional = true, features = [ "derive" ] } -uuid = "1.5" +serde-aux = { version = "4.4", optional = true } +uuid = { version = "1.5", optional = true } +reqwest = { version = "0.11", optional = true } +simd-json = { version = "0.13", optional = true } +regex = { version = "1.10", optional = true } [dev-dependencies] anyhow = "1.0" tokio = { version = "1", features = [ "rt", "rt-multi-thread", "macros", "net" ] } [features] -default = [ "tls-native" ] -serde = [ "dep:serde", "chrono/serde", "uuid/serde" ] -tls-native = [ "irc/tls-native" ] -tls-rust = [ "irc/tls-rust" ] +default = [ "tls-native", "twitch", "youtube" ] +twitch = [ "dep:irc", "dep:uuid" ] +youtube = [ "dep:simd-json", "dep:reqwest", "dep:serde", "dep:regex", "dep:serde-aux" ] +serde = [ "dep:serde", "chrono/serde", "uuid?/serde" ] +tls-native = [ "irc?/tls-native" ] +#tls-rust = [ "irc?/tls-rust" ] diff --git a/README.md b/README.md index c7d9a70..1e27516 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # `brainrot` -A Twitch IRC client. +A live chat interface for Twitch & YouTube written in Rust. ## Usage See [`examples/main.rs`](https://github.com/vitri-ent/brainrot/blob/examples/main.rs). diff --git a/examples/main.rs b/examples/twitch.rs similarity index 55% rename from examples/main.rs rename to examples/twitch.rs index 45fff49..4608d2f 100644 --- a/examples/main.rs +++ b/examples/twitch.rs @@ -1,14 +1,14 @@ use std::env::args; -use brainrot::ChatEvent; +use brainrot::{twitch, TwitchChat, TwitchChatEvent}; use futures_util::StreamExt; #[tokio::main] async fn main() -> anyhow::Result<()> { - let mut client = brainrot::Chat::new(args().nth(1).as_deref().unwrap_or("miyukiwei"), brainrot::Anonymous).await?; + let mut client = TwitchChat::new(args().nth(1).as_deref().unwrap_or("miyukiwei"), twitch::Anonymous).await?; while let Some(message) = client.next().await.transpose()? { - if let ChatEvent::Message { user, contents, .. } = message { + if let TwitchChatEvent::Message { user, contents, .. } = message { println!("{}: {}", user.display_name, contents.iter().map(|c| c.to_string()).collect::()); } } diff --git a/examples/youtube.rs b/examples/youtube.rs new file mode 100644 index 0000000..d558f87 --- /dev/null +++ b/examples/youtube.rs @@ -0,0 +1,12 @@ +use std::env::args; + +use brainrot::youtube; +use futures_util::StreamExt; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let (options, cont) = youtube::get_options_from_live_page("e-5D_Shoozk").await?; + let initial_chat = youtube::fetch_yt_chat_page(&options, cont).await?; + youtube::subscribe_to_events(&options, &initial_chat).await?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 74192ac..081b870 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,80 +1,9 @@ -use std::{ - pin::Pin, - task::{Context, Poll} -}; +#[cfg(feature = "twitch")] +pub mod twitch; +#[cfg(feature = "twitch")] +pub use self::twitch::{Chat as TwitchChat, ChatEvent as TwitchChatEvent, MessageSegment as TwitchMessageSegment, TwitchIdentity}; -use futures_util::{Stream, StreamExt}; -use irc::{ - client::{prelude::Config, Client, ClientStream}, - proto::Capability -}; +#[cfg(feature = "youtube")] +pub mod youtube; -pub mod identity; -pub use self::identity::{Anonymous, Authenticated, TwitchIdentity}; -mod event; -pub use self::event::{ChatEvent, MessageSegment, User, UserRole}; pub(crate) mod util; - -const TWITCH_SECURE_IRC: (&str, u16) = ("irc.chat.twitch.tv", 6697); -const TWITCH_CAPABILITY_TAGS: Capability = Capability::Custom("twitch.tv/tags"); -const TWITCH_CAPABILITY_MEMBERSHIP: Capability = Capability::Custom("twitch.tv/membership"); -const TWITCH_CAPABILITY_COMMANDS: Capability = Capability::Custom("twitch.tv/commands"); - -/// A connection to a Twitch IRC channel. -/// -/// In order for the connection to stay alive, the IRC client must be able to receive and respond to ping messages, thus -/// you must poll the stream for as long as you wish the client to stay alive. If that isn't possible, start a dedicated -/// thread for the client and send chat events back to your application over an `mpsc` or other channel. -#[derive(Debug)] -pub struct Chat { - stream: ClientStream -} - -impl Chat { - /// Connect to a Twitch IRC channel. - /// - /// ```no_run - /// use brainrot::{Anonymous, Chat}; - /// - /// # #[tokio::main] - /// # async fn main() -> anyhow::Result<()> { - /// let mut client = Chat::new("miyukiwei", Anonymous).await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn new(channel: impl AsRef, auth: impl TwitchIdentity) -> irc::error::Result { - let (username, password) = auth.as_identity(); - let mut client = Client::from_config(Config { - server: Some(TWITCH_SECURE_IRC.0.to_string()), - port: Some(TWITCH_SECURE_IRC.1), - nickname: Some(username.to_string()), - password: password.map(|c| format!("oauth:{c}")), - channels: vec![format!("#{}", channel.as_ref())], - ..Default::default() - }) - .await?; - client.send_cap_req(&[TWITCH_CAPABILITY_COMMANDS, TWITCH_CAPABILITY_MEMBERSHIP, TWITCH_CAPABILITY_TAGS])?; - client.identify()?; - Ok(Self { stream: client.stream()? }) - } -} - -impl Stream for Chat { - type Item = irc::error::Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let next = self.stream.poll_next_unpin(cx); - match next { - Poll::Ready(Some(Ok(r))) => match self::event::to_chat_event(r) { - Some(ev) => Poll::Ready(Some(Ok(ev))), - None => { - cx.waker().wake_by_ref(); - Poll::Pending - } - }, - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending - } - } -} diff --git a/src/event.rs b/src/twitch/event.rs similarity index 100% rename from src/event.rs rename to src/twitch/event.rs diff --git a/src/identity.rs b/src/twitch/identity.rs similarity index 100% rename from src/identity.rs rename to src/twitch/identity.rs diff --git a/src/twitch/mod.rs b/src/twitch/mod.rs new file mode 100644 index 0000000..2d90961 --- /dev/null +++ b/src/twitch/mod.rs @@ -0,0 +1,79 @@ +use std::{ + pin::Pin, + task::{Context, Poll} +}; + +use futures_util::{Stream, StreamExt}; +use irc::{ + client::{prelude::Config, Client, ClientStream}, + proto::Capability +}; + +pub mod identity; +pub use self::identity::{Anonymous, Authenticated, TwitchIdentity}; +mod event; +pub use self::event::{ChatEvent, MessageSegment, User, UserRole}; + +const TWITCH_SECURE_IRC: (&str, u16) = ("irc.chat.twitch.tv", 6697); +const TWITCH_CAPABILITY_TAGS: Capability = Capability::Custom("twitch.tv/tags"); +const TWITCH_CAPABILITY_MEMBERSHIP: Capability = Capability::Custom("twitch.tv/membership"); +const TWITCH_CAPABILITY_COMMANDS: Capability = Capability::Custom("twitch.tv/commands"); + +/// A connection to a Twitch IRC channel. +/// +/// In order for the connection to stay alive, the IRC client must be able to receive and respond to ping messages, thus +/// you must poll the stream for as long as you wish the client to stay alive. If that isn't possible, start a dedicated +/// thread for the client and send chat events back to your application over an `mpsc` or other channel. +#[derive(Debug)] +pub struct Chat { + stream: ClientStream +} + +impl Chat { + /// Connect to a Twitch IRC channel. + /// + /// ```no_run + /// use brainrot::{Anonymous, Chat}; + /// + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = Chat::new("miyukiwei", Anonymous).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn new(channel: impl AsRef, auth: impl TwitchIdentity) -> irc::error::Result { + let (username, password) = auth.as_identity(); + let mut client = Client::from_config(Config { + server: Some(TWITCH_SECURE_IRC.0.to_string()), + port: Some(TWITCH_SECURE_IRC.1), + nickname: Some(username.to_string()), + password: password.map(|c| format!("oauth:{c}")), + channels: vec![format!("#{}", channel.as_ref())], + ..Default::default() + }) + .await?; + client.send_cap_req(&[TWITCH_CAPABILITY_COMMANDS, TWITCH_CAPABILITY_MEMBERSHIP, TWITCH_CAPABILITY_TAGS])?; + client.identify()?; + Ok(Self { stream: client.stream()? }) + } +} + +impl Stream for Chat { + type Item = irc::error::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let next = self.stream.poll_next_unpin(cx); + match next { + Poll::Ready(Some(Ok(r))) => match self::event::to_chat_event(r) { + Some(ev) => Poll::Ready(Some(Ok(ev))), + None => { + cx.waker().wake_by_ref(); + Poll::Pending + } + }, + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending + } + } +} diff --git a/src/youtube/mod.rs b/src/youtube/mod.rs new file mode 100644 index 0000000..2091d40 --- /dev/null +++ b/src/youtube/mod.rs @@ -0,0 +1,323 @@ +use std::{ + collections::{HashMap, VecDeque}, + io::BufRead, + sync::OnceLock, + time::{Instant, SystemTime, UNIX_EPOCH} +}; + +use regex::Regex; +use reqwest::{ + get, + header::{self, HeaderMap, HeaderValue, CONTENT_TYPE}, + StatusCode +}; +use simd_json::{ + base::{ValueAsContainer, ValueAsScalar}, + OwnedValue +}; +use thiserror::Error; +use tokio::sync::Mutex; + +mod types; +mod util; +use self::{ + types::{Action, GetLiveChatBody, GetLiveChatResponse, MessageRun}, + util::{SimdJsonRequestBody, SimdJsonResponseBody} +}; + +const GCM_SIGNALER_SRQE: &str = "https://signaler-pa.youtube.com/punctual/v1/chooseServer"; +const GCM_SIGNALER_PSUB: &str = "https://signaler-pa.youtube.com/punctual/multi-watch/channel"; + +const LIVE_CHAT_BASE_TANGO_KEY: &str = "AIzaSyDZNkyC-AtROwMBpLfevIvqYk-Gfi8ZOeo"; + +static USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0"; +static HTTP_CLIENT: OnceLock = OnceLock::new(); + +#[derive(Debug, Error)] +pub enum YouTubeError { + #[error("impossible regex error")] + Regex(#[from] regex::Error), + #[error("error when deserializing: {0}")] + Deserialization(#[from] simd_json::Error), + #[error("missing continuation contents")] + MissingContinuationContents, + #[error("reached end of continuation")] + EndOfContinuation, + #[error("request timed out")] + TimedOut, + #[error("request returned bad HTTP status: {0}")] + BadStatus(StatusCode), + #[error("request error: {0}")] + GeneralRequest(reqwest::Error), + #[error("{0} is not a live stream")] + NotStream(String), + #[error("Failed to match InnerTube API key")] + NoInnerTubeKey, + #[error("Chat continuation token could not be found.")] + NoChatContinuation +} + +impl From for YouTubeError { + fn from(value: reqwest::Error) -> Self { + if value.is_timeout() { + YouTubeError::TimedOut + } else if value.is_status() { + YouTubeError::BadStatus(value.status().unwrap()) + } else { + YouTubeError::GeneralRequest(value) + } + } +} + +pub(crate) fn get_http_client() -> &'static reqwest::Client { + HTTP_CLIENT.get_or_init(|| { + let mut headers = HeaderMap::new(); + // Set our Accept-Language to en-US so we can properly match substrings + headers.append(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5")); + headers.append(header::USER_AGENT, HeaderValue::from_static(USER_AGENT)); + reqwest::Client::builder().default_headers(headers).build().unwrap() + }) +} + +#[derive(Clone, Debug)] +pub struct RequestOptions { + pub api_key: String, + pub client_version: String, + pub live_status: bool +} + +pub async fn get_options_from_live_page(live_id: impl AsRef) -> Result<(RequestOptions, String), YouTubeError> { + let live_id = live_id.as_ref(); + let page_contents = get_http_client() + .get(format!("https://www.youtube.com/watch?v={live_id}")) + .send() + .await? + .text() + .await?; + + let live_status: bool; + let live_now_regex = Regex::new(r#"['"]isLiveNow['"]:\s*(true)"#)?; + let not_replay_regex = Regex::new(r#"['"]isReplay['"]:\s*(true)"#)?; + if live_now_regex.find(&page_contents).is_some() { + live_status = true; + } else if not_replay_regex.find(&page_contents).is_some() { + live_status = false; + } else { + return Err(YouTubeError::NotStream(live_id.to_string())); + } + + let api_key_regex = Regex::new(r#"['"]INNERTUBE_API_KEY['"]:\s*['"](.+?)['"]"#).unwrap(); + let api_key = match api_key_regex.captures(&page_contents).and_then(|captures| captures.get(1)) { + Some(matched) => matched.as_str().to_string(), + None => return Err(YouTubeError::NoInnerTubeKey) + }; + + let client_version_regex = Regex::new(r#"['"]clientVersion['"]:\s*['"]([\d.]+?)['"]"#).unwrap(); + let client_version = match client_version_regex.captures(&page_contents).and_then(|captures| captures.get(1)) { + Some(matched) => matched.as_str().to_string(), + None => "2.20230801.08.00".to_string() + }; + + let continuation_regex = if live_status { + Regex::new( + r#"Live chat['"],\s*['"]selected['"]:\s*(?:true|false),\s*['"]continuation['"]:\s*\{\s*['"]reloadContinuationData['"]:\s*\{['"]continuation['"]:\s*['"](.+?)['"]"# + )? + } else { + Regex::new( + r#"Top chat replay['"],\s*['"]selected['"]:\s*true,\s*['"]continuation['"]:\s*\{\s*['"]reloadContinuationData['"]:\s*\{['"]continuation['"]:\s*['"](.+?)['"]"# + )? + }; + let continuation = match continuation_regex.captures(&page_contents).and_then(|captures| captures.get(1)) { + Some(matched) => matched.as_str().to_string(), + None => return Err(YouTubeError::NoChatContinuation) + }; + + Ok((RequestOptions { api_key, client_version, live_status }, continuation)) +} +pub struct Author { + pub display_name: String, + pub id: String, + pub avatar: String +} + +pub struct ChatMessage { + pub runs: Vec, + pub is_super: bool, + pub author: Author, + pub timestamp: i64, + pub time_delta: i64 +} + +pub struct YouTubeChatPageProcessor<'r> { + actions: Mutex>, + request_options: &'r RequestOptions, + continuation_token: Option +} + +#[derive(Debug, Error)] +#[error("no continuation available")] +pub struct NoContinuationError; + +unsafe impl<'r> Send for YouTubeChatPageProcessor<'r> {} + +impl<'r> YouTubeChatPageProcessor<'r> { + pub fn new(response: GetLiveChatResponse, request_options: &'r RequestOptions, continuation_token: Option) -> Result { + Ok(Self { + actions: Mutex::new(VecDeque::from( + response + .continuation_contents + .ok_or(YouTubeError::MissingContinuationContents)? + .live_chat_continuation + .actions + .ok_or(YouTubeError::EndOfContinuation)? + )), + request_options, + continuation_token + }) + } +} + +impl<'r> Iterator for &YouTubeChatPageProcessor<'r> { + type Item = ChatMessage; + + fn next(&mut self) -> Option { + let mut next_action = None; + while next_action.is_none() { + match self.actions.try_lock().unwrap().pop_front() { + Some(action) => { + if let Some(replay) = action.replay_chat_item_action { + for action in replay.actions { + if next_action.is_some() { + break; + } + + if let Some(add_chat_item_action) = action.add_chat_item_action { + if let Some(text_message_renderer) = &add_chat_item_action.item.live_chat_text_message_renderer { + if text_message_renderer.message.is_some() { + next_action.replace((add_chat_item_action, replay.video_offset_time_msec)); + } + } else if let Some(superchat_renderer) = &add_chat_item_action.item.live_chat_paid_message_renderer { + if superchat_renderer.live_chat_text_message_renderer.message.is_some() { + next_action.replace((add_chat_item_action, replay.video_offset_time_msec)); + } + } + } + } + } + } + None => return None + } + } + + let (next_action, time_delta) = next_action.unwrap(); + let is_super = next_action.item.live_chat_paid_message_renderer.is_some(); + let renderer = if let Some(renderer) = next_action.item.live_chat_text_message_renderer { + renderer + } else if let Some(renderer) = next_action.item.live_chat_paid_message_renderer { + renderer.live_chat_text_message_renderer + } else { + panic!() + }; + + Some(ChatMessage { + runs: renderer.message.unwrap().runs, + is_super, + author: Author { + display_name: renderer + .message_renderer_base + .author_name + .map(|x| x.simple_text) + .unwrap_or_else(|| renderer.message_renderer_base.author_external_channel_id.to_owned()), + id: renderer.message_renderer_base.author_external_channel_id.to_owned(), + avatar: renderer.message_renderer_base.author_photo.thumbnails[renderer.message_renderer_base.author_photo.thumbnails.len() - 1] + .url + .to_owned() + }, + timestamp: renderer.message_renderer_base.timestamp_usec.timestamp_millis(), + time_delta + }) + } +} + +pub async fn fetch_yt_chat_page(options: &RequestOptions, continuation: impl AsRef) -> Result { + let url = + format!("https://www.youtube.com/youtubei/v1/live_chat/get_live_chat{}?key={}", if !options.live_status { "_replay" } else { "" }, &options.api_key); + let body = GetLiveChatBody::new(continuation.as_ref(), &options.client_version, "WEB"); + let response = get_http_client().post(url).simd_json(&body)?.send().await?; + let response: GetLiveChatResponse = response.simd_json().await?; + Ok(response) +} + +pub async fn subscribe_to_events(options: &RequestOptions, initial_continuation: &GetLiveChatResponse) -> Result<(), YouTubeError> { + let topic_id = &initial_continuation + .continuation_contents + .as_ref() + .unwrap() + .live_chat_continuation + .continuations[0] + .invalidation_continuation_data + .as_ref() + .unwrap() + .invalidation_id + .topic; + + let server_response: OwnedValue = get_http_client() + .post(format!("{GCM_SIGNALER_SRQE}?key={}", LIVE_CHAT_BASE_TANGO_KEY)) + .header(header::CONTENT_TYPE, "application/json+protobuf") + .header(header::REFERER, "https://www.youtube.com/") + .header("Sec-Fetch-Site", "same-site") + .header(header::ORIGIN, "https://www.youtube.com/") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .simd_json(&simd_json::json!([[null, null, null, [7, 5], null, [["youtube_live_chat_web"], [1], [[[&topic_id]]]]]]))? + .send() + .await? + .simd_json() + .await?; + let gsess = server_response.as_array().unwrap()[0].as_str().unwrap(); + + let mut ofs_parameters = HashMap::new(); + ofs_parameters.insert("count", "1".to_string()); + ofs_parameters.insert("ofs", "0".to_string()); + ofs_parameters.insert( + "req0___data__", + format!( + r#"[[["1",[null,null,null,[7,5],null,[["youtube_live_chat_web"],[1],[[["{}"]]]],null,null,1],null,3]]]"#, + &topic_id + ) + ); + let ofs = get_http_client() + .post(format!("{GCM_SIGNALER_PSUB}?VER=8&gsessionid={gsess}&key={LIVE_CHAT_BASE_TANGO_KEY}&RID=60464&CVER=22&zx=uo5vp9j380ef&t=1")) + .header(header::REFERER, "https://www.youtube.com/") + .header("Sec-Fetch-Site", "same-site") + .header(header::ORIGIN, "https://www.youtube.com/") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .header("X-WebChannel-Content-Type", "application/json+protobuf") + .form(&ofs_parameters) + .send() + .await?; + + let mut ofs_res_line = ofs.bytes().await?.lines().nth(1).unwrap().unwrap(); + let value: OwnedValue = unsafe { simd_json::from_str(&mut ofs_res_line) }?; + let value = value.as_array().unwrap()[0].as_array().unwrap(); + assert_eq!(value[0].as_usize().unwrap(), 0); + let sid = value[1].as_array().unwrap()[1].as_str().unwrap(); + + let mut stream = get_http_client() + .get(format!( + "{GCM_SIGNALER_PSUB}?VER=8&gsessionid={gsess}&key={LIVE_CHAT_BASE_TANGO_KEY}&RID=rpc&SID={sid}&AID=0&CI=0&TYPE=xmlhttp&zx=uo5vp9j380ed&t=1" + )) + .header(header::REFERER, "https://www.youtube.com/") + .header("Sec-Fetch-Site", "same-site") + .header(header::ORIGIN, "https://www.youtube.com/") + .header(header::ACCEPT_ENCODING, "gzip, deflate, br") + .header(header::ACCEPT, "*/*") + .header(header::CONNECTION, "keep-alive") + .send() + .await?; + + while let Some(c) = stream.chunk().await? { + println!("{}", String::from_utf8_lossy(&c)); + } + + Ok(()) +} diff --git a/src/youtube/types.rs b/src/youtube/types.rs new file mode 100644 index 0000000..182b1b8 --- /dev/null +++ b/src/youtube/types.rs @@ -0,0 +1,319 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_aux::prelude::*; + +#[derive(Serialize, Debug)] +pub struct GetLiveChatBody { + context: GetLiveChatBodyContext, + continuation: String +} + +impl GetLiveChatBody { + pub fn new(continuation: impl Into, client_version: impl Into, client_name: impl Into) -> Self { + Self { + context: GetLiveChatBodyContext { + client: GetLiveChatBodyContextClient { + client_version: client_version.into(), + client_name: client_name.into() + } + }, + continuation: continuation.into() + } + } +} + +#[derive(Serialize, Debug)] +pub struct GetLiveChatBodyContext { + client: GetLiveChatBodyContextClient +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetLiveChatBodyContextClient { + client_version: String, + client_name: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetLiveChatResponse { + pub response_context: Option, + pub tracking_params: Option, + pub continuation_contents: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetLiveChatResponseContinuationContents { + pub live_chat_continuation: LiveChatContinuation +} +#[derive(Deserialize, Debug)] +pub struct LiveChatContinuation { + pub continuations: Vec, + pub actions: Option> +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Continuation { + pub invalidation_continuation_data: Option, + pub timed_continuation_data: Option, + pub live_chat_replay_continuation_data: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LiveChatReplayContinuationData { + pub time_until_last_message_msec: usize, + pub continuation: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InvalidationContinuationData { + pub invalidation_id: InvalidationId, + pub timeout_ms: usize, + pub continuation: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InvalidationId { + pub object_source: usize, + pub object_id: String, + pub topic: String, + pub subscribe_to_gcm_topics: bool, + pub proto_creation_timestamp_ms: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TimedContinuationData { + pub timeout_ms: usize, + pub continuation: String, + pub click_tracking_params: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Action { + pub add_chat_item_action: Option, + pub add_live_chat_ticker_item_action: Option, + pub replay_chat_item_action: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ReplayChatItemAction { + pub actions: Vec, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub video_offset_time_msec: i64 +} + +// MessageRun +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum MessageRun { + MessageText { + text: String + }, + #[serde(rename_all = "camelCase")] + MessageEmoji { + emoji: Emoji, + variant_ids: Option> + } +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Emoji { + pub emoji_id: String, + pub shortcuts: Option>, + pub search_terms: Option>, + pub supports_skin_tone: Option, + pub image: Image, + pub is_custom_emoji: Option +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Image { + pub thumbnails: Vec, + pub accessibility: Accessibility +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Accessibility { + pub accessibility_data: AccessibilityData +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AccessibilityData { + pub label: String +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Thumbnail { + pub url: String, + pub width: Option, + pub height: Option +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AuthorBadge { + pub live_chat_author_badge_renderer: LiveChatAuthorBadgeRenderer +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LiveChatAuthorBadgeRenderer { + pub custom_thumbnail: Option, + pub icon: Option, + pub tooltip: String, + pub accessibility: Accessibility +} + +#[derive(Deserialize, Debug, Clone)] +pub struct CustomThumbnail { + pub thumbnails: Vec +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Icon { + pub icon_type: String +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MessageRendererBase { + pub author_name: Option, + pub author_photo: AuthorPhoto, + pub author_badges: Option>, + pub context_menu_endpoint: ContextMenuEndpoint, + pub id: String, + #[serde(deserialize_with = "deserialize_datetime_utc_from_milliseconds")] + pub timestamp_usec: DateTime, + pub author_external_channel_id: String, + pub context_menu_accessibility: Accessibility +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ContextMenuEndpoint { + pub click_tracking_params: Option, + pub command_metadata: CommandMetadata, + pub live_chat_item_context_menu_endpoint: LiveChatItemContextMenuEndpoint +} + +#[derive(Deserialize, Debug, Clone)] +pub struct LiveChatItemContextMenuEndpoint { + pub params: String +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CommandMetadata { + pub web_command_metadata: WebCommandMetadata +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WebCommandMetadata { + pub ignore_navigation: bool +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AuthorPhoto { + pub thumbnails: Vec +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AuthorName { + pub simple_text: String +} + +#[derive(Deserialize, Debug)] +pub struct LiveChatTextMessageRenderer { + #[serde(flatten)] + pub message_renderer_base: MessageRendererBase, + pub message: Option +} + +#[derive(Deserialize, Debug)] +pub struct Message { + pub runs: Vec +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LiveChatPaidMessageRenderer { + #[serde(flatten)] + pub live_chat_text_message_renderer: LiveChatTextMessageRenderer, + pub purchase_amount_text: PurchaseAmountText, + pub header_background_color: isize, + pub header_text_color: isize, + pub body_background_color: isize, + pub body_text_color: isize, + pub author_name_text_color: isize +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LiveChatPaidStickerRenderer { + #[serde(flatten)] + pub message_renderer_base: MessageRendererBase, + pub purchase_amount_text: PurchaseAmountText, + pub sticker: Sticker, + pub money_chip_background_color: isize, + pub money_chip_text_color: isize, + pub sticker_display_width: isize, + pub sticker_display_height: isize, + pub background_color: isize, + pub author_name_text_color: isize +} + +#[derive(Deserialize, Debug)] +pub struct Sticker { + pub thumbnails: Vec, + pub accessibility: Accessibility +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PurchaseAmountText { + pub simple_text: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LiveChatMembershipItemRenderer { + #[serde(flatten)] + pub message_renderer_base: MessageRendererBase, + pub header_sub_text: Option, + pub author_badges: Option> +} + +#[derive(Deserialize, Debug)] +pub struct HeaderSubText { + pub runs: Vec +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AddChatItemAction { + pub item: ActionItem, + pub client_id: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ActionItem { + pub live_chat_text_message_renderer: Option, + pub live_chat_paid_message_renderer: Option, + pub live_chat_membership_item_renderer: Option, + pub live_chat_paid_sticker_renderer: Option, + pub live_chat_viewer_engagement_message_renderer: Option +} diff --git a/src/youtube/util.rs b/src/youtube/util.rs new file mode 100644 index 0000000..98f57e2 --- /dev/null +++ b/src/youtube/util.rs @@ -0,0 +1,29 @@ +use std::future::Future; + +use reqwest::{RequestBuilder, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +use super::YouTubeError; + +pub trait SimdJsonResponseBody { + fn simd_json(self) -> impl Future>; +} + +impl SimdJsonResponseBody for Response { + async fn simd_json(self) -> Result { + let mut full = self.bytes().await?.to_vec(); + Ok(simd_json::from_slice(&mut full)?) + } +} + +pub trait SimdJsonRequestBody { + fn simd_json(self, json: &T) -> Result + where + Self: Sized; +} + +impl SimdJsonRequestBody for RequestBuilder { + fn simd_json(self, json: &T) -> Result { + Ok(self.body(simd_json::to_vec(json)?)) + } +}