diff --git a/Cargo.toml b/Cargo.toml index a867f96..ca6f335 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,36 @@ [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", 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 } +url = { version = "2.5", optional = true } +rand = { version = "0.8", optional = true } +regex = { version = "1.10", optional = true } +async-stream = "0.3" [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:rand", "dep:serde", "dep:url", "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..38962ad 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # `brainrot` -A Twitch IRC client. +A live chat interface for Twitch & YouTube written in Rust. + +## Features +- **Twitch** + * ⚡ Live IRC + * 🔓 No authentication required +- **YouTube** + * 🏆 Receive chats in real time - first library to do so + * ⚡ Low latency + * ⏪ Supports VODs + * 🔓 No authentication required ## Usage -See [`examples/main.rs`](https://github.com/vitri-ent/brainrot/blob/examples/main.rs). +See [`examples/twitch.rs`](https://github.com/vitri-ent/brainrot/blob/examples/twitch.rs) & [`examples/youtube.rs`](https://github.com/vitri-ent/brainrot/blob/examples/youtube.rs). ```shell -$ cargo run --example main -- sinder +$ cargo run --example twitch -- sinder Spartan_N1ck: Very Generous luisfelipee23: GIGACHAD wifi882: GIGACHAD @@ -15,4 +25,15 @@ buddy_boy_joe: @sharkboticus ah LOL fair enough sinder6Laugh sinder6Laugh sinder KateRosaline14: Merry Christmas ThrillGamer2002: FirstTimeChatter ... + +$ cargo run --example youtube -- "@FUWAMOCOch" +Konami Code: makes sense +Wicho4568🐾: thank you biboo +retro: Lol +GLC H 🪐: Thanks Biboo? :face-blue-smiling::FUWAhm: +Ar5eN Vines: lol +Jic: HAHAHA +Rukh 397: :FUWAhm: +PaakType: :FUWApat::MOCOpat::FUWApat::MOCOpat: +... ``` diff --git a/examples/main.rs b/examples/main.rs deleted file mode 100644 index 45fff49..0000000 --- a/examples/main.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::env::args; - -use brainrot::ChatEvent; -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?; - - while let Some(message) = client.next().await.transpose()? { - if let ChatEvent::Message { user, contents, .. } = message { - println!("{}: {}", user.display_name, contents.iter().map(|c| c.to_string()).collect::()); - } - } - - Ok(()) -} diff --git a/examples/twitch.rs b/examples/twitch.rs new file mode 100644 index 0000000..acfa89f --- /dev/null +++ b/examples/twitch.rs @@ -0,0 +1,31 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::env::args; + +use brainrot::{twitch, TwitchChat, TwitchChatEvent}; +use futures_util::StreamExt; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + 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 TwitchChatEvent::Message { user, contents, .. } = message { + println!("{}: {}", user.display_name, contents.iter().map(|c| c.to_string()).collect::()); + } + } + + Ok(()) +} diff --git a/examples/youtube.rs b/examples/youtube.rs new file mode 100644 index 0000000..1a3b7d2 --- /dev/null +++ b/examples/youtube.rs @@ -0,0 +1,39 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::env::args; + +use brainrot::youtube::{self, Action, ChatItem}; +use futures_util::StreamExt; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let context = + youtube::ChatContext::new_from_channel(args().nth(1).as_deref().unwrap_or("@miyukiwei"), youtube::ChannelSearchOptions::LatestLiveOrUpcoming).await?; + let mut stream = youtube::stream(&context).await?; + while let Some(Ok(c)) = stream.next().await { + if let Action::AddChatItem { + item: ChatItem::TextMessage { message_renderer_base, message }, + .. + } = c + { + println!( + "{}: {}", + message_renderer_base.author_name.unwrap().simple_text, + message.unwrap().runs.into_iter().map(|c| c.to_chat_string()).collect::() + ); + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 74192ac..801f754 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,80 +1,23 @@ -use std::{ - pin::Pin, - task::{Context, Poll} -}; +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -use futures_util::{Stream, StreamExt}; -use irc::{ - client::{prelude::Config, Client, ClientStream}, - proto::Capability -}; +#[cfg(feature = "twitch")] +pub mod twitch; +#[cfg(feature = "twitch")] +pub use self::twitch::{Chat as TwitchChat, ChatEvent as TwitchChatEvent, MessageSegment as TwitchMessageSegment, TwitchIdentity}; -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 -} +#[cfg(feature = "youtube")] +pub mod youtube; -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 - } - } -} +pub(crate) mod util; diff --git a/src/event.rs b/src/twitch/event.rs similarity index 92% rename from src/event.rs rename to src/twitch/event.rs index eb67c2c..82900af 100644 --- a/src/event.rs +++ b/src/twitch/event.rs @@ -1,3 +1,17 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + use std::{ collections::HashMap, num::{NonZeroU16, NonZeroU32} diff --git a/src/identity.rs b/src/twitch/identity.rs similarity index 68% rename from src/identity.rs rename to src/twitch/identity.rs index d70c19f..cc29c24 100644 --- a/src/identity.rs +++ b/src/twitch/identity.rs @@ -1,3 +1,17 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /// Represents a type that can be used to identify the client. pub trait TwitchIdentity { /// Converts this type into a tuple of `(username, Option)`. @@ -23,7 +37,7 @@ impl TwitchIdentity for Anonymous { /// use [`Anonymous`] instead. /// /// ```no_run -/// use brainrot::{Authenticated, Chat}; +/// use brainrot::twitch::{Authenticated, Chat}; /// /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { diff --git a/src/twitch/mod.rs b/src/twitch/mod.rs new file mode 100644 index 0000000..86b3ed2 --- /dev/null +++ b/src/twitch/mod.rs @@ -0,0 +1,93 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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::twitch::{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/util.rs b/src/util.rs index ed16f3c..09670e6 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,17 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + pub trait MapNonempty { type T; diff --git a/src/youtube/context.rs b/src/youtube/context.rs new file mode 100644 index 0000000..0f5878f --- /dev/null +++ b/src/youtube/context.rs @@ -0,0 +1,282 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::OnceLock; + +use regex::Regex; +use url::Url; + +use super::{ + get_http_client, + types::streams_page::{ + FeedContentsRenderer, PageContentsRenderer, RichGridItem, RichItemContent, TabItemRenderer, ThumbnailOverlay, VideoTimeStatus, YouTubeInitialData + }, + Error +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiveStreamStatus { + Upcoming, + Live, + Replay +} + +impl LiveStreamStatus { + #[inline] + pub fn updates_live(&self) -> bool { + matches!(self, LiveStreamStatus::Upcoming | LiveStreamStatus::Live) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ChannelSearchOptions { + /// Get the live chat of the latest live stream, or the pre-stream chat of the latest upcoming stream if no stream + /// is currently live. + LatestLiveOrUpcoming, + /// Get the live chat of the first live stream, or the pre-stream chat of the first upcoming stream if no stream + /// is currently live. + #[default] + FirstLiveOrUpcoming, + /// Get the live chat of the first live stream. + FirstLive, + /// Get the live chat of the latest live stream. + LatestLive +} + +#[derive(Clone, Debug)] +pub struct ChatContext { + pub(crate) id: String, + pub(crate) api_key: String, + pub(crate) client_version: String, + pub(crate) initial_continuation: String, + pub(crate) tango_api_key: Option, + pub(crate) live_status: LiveStreamStatus +} + +impl ChatContext { + pub async fn new_from_channel(channel_id: impl AsRef, options: ChannelSearchOptions) -> Result { + let channel_id = channel_id.as_ref(); + let channel_id = if channel_id.starts_with("UC") || channel_id.starts_with('@') { + channel_id + } else { + Self::parse_channel_link(channel_id).ok_or_else(|| Error::InvalidChannelID(channel_id.to_string()))? + }; + let page_contents = get_http_client() + .get(if channel_id.starts_with('@') { + format!("https://www.youtube.com/{channel_id}/streams") + } else { + format!("https://www.youtube.com/channel/{channel_id}/streams") + }) + .send() + .await? + .text() + .await?; + + static YT_INITIAL_DATA_REGEX: OnceLock = OnceLock::new(); + let yt_initial_data: YouTubeInitialData = unsafe { + simd_json::from_str( + &mut YT_INITIAL_DATA_REGEX + .get_or_init(|| Regex::new(r#"var ytInitialData\s*=\s*(\{.+?\});"#).unwrap()) + .captures(&page_contents) + .ok_or_else(|| Error::NoChatContinuation)? + .get(1) + .ok_or(Error::MissingInitialData)? + .as_str() + .to_owned() + ) + }?; + + let mut live_id = None; + match yt_initial_data.contents { + PageContentsRenderer::TwoColumnBrowseResultsRenderer { tabs } => match tabs + .iter() + .find(|c| match c { + TabItemRenderer::TabRenderer { title, content, .. } => content.is_some() && title == "Live", + TabItemRenderer::ExpandableTabRenderer { .. } => false + }) + .ok_or_else(|| Error::NoMatchingStream(channel_id.to_string()))? + { + TabItemRenderer::TabRenderer { content, .. } => match content.as_ref().unwrap() { + FeedContentsRenderer::RichGridRenderer { contents, .. } => { + let finder = |c: &&RichGridItem| match c { + RichGridItem::RichItemRenderer { content, .. } => match content { + RichItemContent::VideoRenderer { thumbnail_overlays, video_id, .. } => thumbnail_overlays.iter().any(|c| match c { + ThumbnailOverlay::TimeStatus { style, .. } => { + if *style == VideoTimeStatus::Live { + live_id = Some((video_id.to_owned(), true)); + true + } else { + if *style == VideoTimeStatus::Upcoming + && matches!(options, ChannelSearchOptions::FirstLiveOrUpcoming | ChannelSearchOptions::LatestLiveOrUpcoming) + { + match &live_id { + None => { + live_id = Some((video_id.to_owned(), false)); + } + Some((_, false)) => { + live_id = Some((video_id.to_owned(), false)); + } + Some((_, true)) => {} + } + } + false + } + } + _ => false + }) + }, + RichGridItem::ContinuationItemRenderer { .. } => false + }; + if matches!(options, ChannelSearchOptions::FirstLive | ChannelSearchOptions::FirstLiveOrUpcoming) { + contents.iter().rev().find(finder) + } else { + contents.iter().find(finder) + } + .ok_or_else(|| Error::NoMatchingStream(channel_id.to_string()))? + } + FeedContentsRenderer::SectionListRenderer { .. } => return Err(Error::NoMatchingStream(channel_id.to_string())) + }, + TabItemRenderer::ExpandableTabRenderer { .. } => unreachable!() + } + }; + + ChatContext::new_from_live(live_id.ok_or_else(|| Error::NoMatchingStream(channel_id.to_string()))?.0).await + } + + pub async fn new_from_live(id: impl AsRef) -> Result { + let id = id.as_ref(); + let live_id = if id.is_ascii() && id.len() == 11 { + id + } else { + Self::parse_stream_link(id).ok_or_else(|| Error::InvalidVideoID(id.to_string()))? + }; + let page_contents = get_http_client() + .get(format!("https://www.youtube.com/watch?v={live_id}")) + .send() + .await? + .text() + .await?; + + static LIVE_STREAM_REGEX: OnceLock = OnceLock::new(); + let live_status = if LIVE_STREAM_REGEX + .get_or_init(|| Regex::new(r#"['"]isLiveContent['"]:\s*(true)"#).unwrap()) + .find(&page_contents) + .is_some() + { + static LIVE_NOW_REGEX: OnceLock = OnceLock::new(); + static REPLAY_REGEX: OnceLock = OnceLock::new(); + if LIVE_NOW_REGEX + .get_or_init(|| Regex::new(r#"['"]isLiveNow['"]:\s*(true)"#).unwrap()) + .find(&page_contents) + .is_some() + { + LiveStreamStatus::Live + } else if REPLAY_REGEX + .get_or_init(|| Regex::new(r#"['"]isReplay['"]:\s*(true)"#).unwrap()) + .find(&page_contents) + .is_some() + { + LiveStreamStatus::Replay + } else { + LiveStreamStatus::Upcoming + } + } else { + return Err(Error::NotStream(live_id.to_string())); + }; + + static INNERTUBE_API_KEY_REGEX: OnceLock = OnceLock::new(); + let api_key = match INNERTUBE_API_KEY_REGEX + .get_or_init(|| Regex::new(r#"['"]INNERTUBE_API_KEY['"]:\s*['"](.+?)['"]"#).unwrap()) + .captures(&page_contents) + .and_then(|captures| captures.get(1)) + { + Some(matched) => matched.as_str().to_string(), + None => return Err(Error::NoInnerTubeKey) + }; + + static TANGO_API_KEY_REGEX: OnceLock = OnceLock::new(); + let tango_api_key = TANGO_API_KEY_REGEX + .get_or_init(|| Regex::new(r#"['"]LIVE_CHAT_BASE_TANGO_CONFIG['"]:\s*\{\s*['"]apiKey['"]\s*:\s*['"](.+?)['"]"#).unwrap()) + .captures(&page_contents) + .and_then(|captures| captures.get(1).map(|c| c.as_str().to_string())); + + static CLIENT_VERSION_REGEX: OnceLock = OnceLock::new(); + let client_version = match CLIENT_VERSION_REGEX + .get_or_init(|| Regex::new(r#"['"]clientVersion['"]:\s*['"]([\d.]+?)['"]"#).unwrap()) + .captures(&page_contents) + .and_then(|captures| captures.get(1)) + { + Some(matched) => matched.as_str().to_string(), + None => "2.20240207.07.00".to_string() + }; + + static LIVE_CONTINUATION_REGEX: OnceLock = OnceLock::new(); + static REPLAY_CONTINUATION_REGEX: OnceLock = OnceLock::new(); + let continuation_regex = if live_status.updates_live() { + LIVE_CONTINUATION_REGEX.get_or_init(|| Regex::new( + r#"Live chat['"],\s*['"]selected['"]:\s*(?:true|false),\s*['"]continuation['"]:\s*\{\s*['"]reloadContinuationData['"]:\s*\{['"]continuation['"]:\s*['"](.+?)['"]"# + ).unwrap()) + } else { + REPLAY_CONTINUATION_REGEX.get_or_init(|| { + Regex::new( + r#"Top chat replay['"],\s*['"]selected['"]:\s*true,\s*['"]continuation['"]:\s*\{\s*['"]reloadContinuationData['"]:\s*\{['"]continuation['"]:\s*['"](.+?)['"]"# + ) + .unwrap() + }) + }; + let continuation = match continuation_regex.captures(&page_contents).and_then(|captures| captures.get(1)) { + Some(matched) => matched.as_str().to_string(), + None => return Err(Error::NoChatContinuation) + }; + + Ok(ChatContext { + id: live_id.to_string(), + api_key, + client_version, + tango_api_key, + initial_continuation: continuation, + live_status + }) + } + + fn parse_stream_link(url: &str) -> Option<&str> { + static LINK_RE: OnceLock = OnceLock::new(); + LINK_RE + .get_or_init(|| Regex::new(r#"(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([A-Za-z0-9-_]+)"#).unwrap()) + .captures(url) + .and_then(|c| c.get(1)) + .map(|c| c.as_str()) + } + + fn parse_channel_link(url: &str) -> Option<&str> { + static CHANNEL_RE: OnceLock = OnceLock::new(); + CHANNEL_RE + .get_or_init(|| Regex::new(r#"^(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:channel\/(UC[\w-]{21}[AQgw])|(@[\w]+))$"#).unwrap()) + .captures(url) + .and_then(|c| c.get(1)) + .map(|c| c.as_str()) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn url(&self) -> Url { + Url::parse(&format!("https://www.youtube.com/watch?v={}", self.id)).unwrap() + } + + pub fn status(&self) -> LiveStreamStatus { + self.live_status + } +} diff --git a/src/youtube/error.rs b/src/youtube/error.rs new file mode 100644 index 0000000..dbfb3e2 --- /dev/null +++ b/src/youtube/error.rs @@ -0,0 +1,66 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use reqwest::StatusCode; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Invalid YouTube video ID or URL: {0}")] + InvalidVideoID(String), + #[error("Invalid YouTube channel ID or URL: {0}")] + InvalidChannelID(String), + #[error("Channel {0} has no live stream matching the options criteria")] + NoMatchingStream(String), + #[error("Missing `ytInitialData` structure from channel streams page.")] + MissingInitialData, + #[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, + #[error("Error parsing URL: {0}")] + URLParseError(#[from] url::ParseError) +} + +impl Error { + pub fn is_fatal(&self) -> bool { + !matches!(self, Error::TimedOut) + } +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + if value.is_timeout() { + Error::TimedOut + } else if value.is_status() { + Error::BadStatus(value.status().unwrap()) + } else { + Error::GeneralRequest(value) + } + } +} diff --git a/src/youtube/mod.rs b/src/youtube/mod.rs new file mode 100644 index 0000000..a95d5e4 --- /dev/null +++ b/src/youtube/mod.rs @@ -0,0 +1,318 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::HashSet, io::BufRead, pin::Pin, sync::OnceLock, time::Duration}; + +use futures_util::Stream; +use reqwest::header::{self, HeaderMap, HeaderValue}; +use simd_json::base::{ValueAsContainer, ValueAsScalar}; +use thiserror::Error; +use tokio::time::sleep; + +mod context; +mod error; +mod signaler; +mod types; +mod util; + +pub use self::{ + context::{ChannelSearchOptions, ChatContext, LiveStreamStatus}, + error::Error, + types::{ + get_live_chat::{Action, ChatItem, MessageRendererBase}, + ImageContainer, LocalizedRun, LocalizedText, Thumbnail, UnlocalizedText + } +}; +use self::{ + signaler::SignalerChannelInner, + types::get_live_chat::{Continuation, GetLiveChatResponse} +}; + +const TANGO_LIVE_ENDPOINT: &str = "https://www.youtube.com/youtubei/v1/live_chat/get_live_chat"; +const TANGO_REPLAY_ENDPOINT: &str = "https://www.youtube.com/youtubei/v1/live_chat/get_live_chat_replay"; + +pub(crate) fn get_http_client() -> &'static reqwest::Client { + static HTTP_CLIENT: OnceLock = OnceLock::new(); + 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("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")); + // Referer is required by Signaler endpoints. + headers.append(header::REFERER, HeaderValue::from_static("https://www.youtube.com/")); + reqwest::Client::builder().default_headers(headers).build().unwrap() + }) +} + +struct ActionChunk<'r> { + actions: Vec, + ctx: &'r ChatContext, + continuation_token: Option, + pub(crate) signaler_topic: Option +} + +unsafe impl<'r> Send for ActionChunk<'r> {} + +impl<'r> ActionChunk<'r> { + pub fn new(response: GetLiveChatResponse, ctx: &'r ChatContext) -> Result { + let continuation_token = match &response.continuation_contents.live_chat_continuation.continuations[0] { + Continuation::Invalidation { continuation, .. } => continuation.to_owned(), + Continuation::Timed { continuation, .. } => continuation.to_owned(), + Continuation::Replay { continuation, .. } => continuation.to_owned(), + Continuation::PlayerSeek { .. } => return Err(Error::EndOfContinuation) + }; + let signaler_topic = match &response.continuation_contents.live_chat_continuation.continuations[0] { + Continuation::Invalidation { invalidation_id, .. } => Some(invalidation_id.topic.to_owned()), + _ => None + }; + Ok(Self { + actions: if ctx.live_status.updates_live() { + response + .continuation_contents + .live_chat_continuation + .actions + .unwrap_or_default() + .into_iter() + .map(|f| f.action) + .collect() + } else { + response + .continuation_contents + .live_chat_continuation + .actions + .ok_or(Error::EndOfContinuation)? + .into_iter() + .flat_map(|f| match f.action { + Action::ReplayChat { actions, .. } => actions.into_iter().map(|f| f.action).collect(), + f => vec![f] + }) + .collect() + }, + ctx, + continuation_token: Some(continuation_token), + signaler_topic + }) + } + + pub fn iter(&self) -> std::slice::Iter<'_, Action> { + self.actions.iter() + } + + async fn next_page(&self, continuation_token: &String) -> Result { + let page = GetLiveChatResponse::fetch(self.ctx, continuation_token).await?; + ActionChunk::new(page, self.ctx) + } + + pub async fn cont(&self) -> Option> { + if let Some(continuation_token) = &self.continuation_token { + Some(self.next_page(continuation_token).await) + } else { + None + } + } +} + +impl<'r> IntoIterator for ActionChunk<'r> { + type Item = Action; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.actions.into_iter() + } +} + +pub async fn stream(options: &ChatContext) -> Result> + '_>>, Error> { + let initial_chat = GetLiveChatResponse::fetch(options, &options.initial_continuation).await?; + + let (mut yield_tx, yield_rx) = unsafe { async_stream::__private::yielder::pair() }; + + Ok(Box::pin(async_stream::__private::AsyncStream::new(yield_rx, async move { + let mut seen_messages = HashSet::new(); + + match &initial_chat.continuation_contents.live_chat_continuation.continuations[0] { + Continuation::Invalidation { invalidation_id, .. } => { + let topic = invalidation_id.topic.to_owned(); + + let mut chunk = ActionChunk::new(initial_chat, options).unwrap(); + + let mut channel = SignalerChannelInner::with_topic(topic, options.tango_api_key.as_ref().unwrap()); + channel.choose_server().await.unwrap(); + channel.init_session().await.unwrap(); + + for action in chunk.iter() { + match action { + Action::AddChatItem { item, .. } => { + if !seen_messages.contains(item.id()) { + yield_tx.send(Ok(action.to_owned())).await; + seen_messages.insert(item.id().to_owned()); + } + } + Action::ReplayChat { actions, .. } => { + for action in actions { + if let Action::AddChatItem { .. } = action.action { + yield_tx.send(Ok(action.action.to_owned())).await; + } + } + } + action => { + yield_tx.send(Ok(action.to_owned())).await; + } + } + } + + 'i: loop { + match chunk.cont().await { + Some(Ok(c)) => chunk = c, + Some(Err(err)) => eprintln!("{err:?}"), + _ => break 'i + }; + + for action in chunk.iter() { + match action { + Action::AddChatItem { item, .. } => { + if !seen_messages.contains(item.id()) { + yield_tx.send(Ok(action.to_owned())).await; + seen_messages.insert(item.id().to_owned()); + } + } + Action::ReplayChat { actions, .. } => { + for action in actions { + if let Action::AddChatItem { .. } = action.action { + yield_tx.send(Ok(action.action.to_owned())).await; + } + } + } + action => { + yield_tx.send(Ok(action.to_owned())).await; + } + } + } + + let mut req = { + channel.reset(); + channel.choose_server().await.unwrap(); + channel.init_session().await.unwrap(); + channel.get_session_stream().await.unwrap() + }; + loop { + match req.chunk().await { + Ok(Some(s)) => { + let mut ofs_res_line = s.lines().nth(1).unwrap().unwrap(); + if let Ok(s) = unsafe { simd_json::from_str::(ofs_res_line.as_mut()) } { + let a = s.as_array().unwrap(); + { + channel.aid = a[a.len() - 1].as_array().unwrap()[0].as_usize().unwrap(); + } + } + + match chunk.cont().await { + Some(Ok(c)) => chunk = c, + Some(Err(err)) => eprintln!("{err:?}"), + _ => break 'i + }; + channel.topic = chunk.signaler_topic.clone().unwrap(); + + for action in chunk.iter() { + match action { + Action::AddChatItem { item, .. } => { + if !seen_messages.contains(item.id()) { + yield_tx.send(Ok(action.to_owned())).await; + seen_messages.insert(item.id().to_owned()); + } + } + Action::ReplayChat { actions, .. } => { + for action in actions { + if let Action::AddChatItem { .. } = action.action { + yield_tx.send(Ok(action.action.to_owned())).await; + } + } + } + action => { + yield_tx.send(Ok(action.to_owned())).await; + } + } + } + } + Ok(None) => break, + Err(e) => { + eprintln!("{e:?}"); + break; + } + } + } + + seen_messages.clear(); + } + } + Continuation::Replay { .. } => { + let mut chunk = ActionChunk::new(initial_chat, options).unwrap(); + loop { + for action in chunk.iter() { + match action { + Action::AddChatItem { .. } => { + yield_tx.send(Ok(action.to_owned())).await; + } + Action::ReplayChat { actions, .. } => { + for action in actions { + if let Action::AddChatItem { .. } = action.action { + yield_tx.send(Ok(action.action.to_owned())).await; + } + } + } + action => { + yield_tx.send(Ok(action.to_owned())).await; + } + } + } + match chunk.cont().await { + Some(Ok(e)) => chunk = e, + _ => break + } + } + } + Continuation::Timed { timeout_ms, .. } => { + let timeout = Duration::from_millis(*timeout_ms as _); + let mut chunk = ActionChunk::new(initial_chat, options).unwrap(); + loop { + for action in chunk.iter() { + match action { + Action::AddChatItem { item, .. } => { + if !seen_messages.contains(item.id()) { + yield_tx.send(Ok(action.to_owned())).await; + seen_messages.insert(item.id().to_owned()); + } + } + Action::ReplayChat { actions, .. } => { + for action in actions { + if let Action::AddChatItem { .. } = action.action { + yield_tx.send(Ok(action.action.to_owned())).await; + } + } + } + action => { + yield_tx.send(Ok(action.to_owned())).await; + } + } + } + sleep(timeout).await; + match chunk.cont().await { + Some(Ok(e)) => chunk = e, + _ => break + } + } + } + Continuation::PlayerSeek { .. } => panic!("player seek should not be first continuation") + } + }))) +} diff --git a/src/youtube/signaler.rs b/src/youtube/signaler.rs new file mode 100644 index 0000000..3c686f8 --- /dev/null +++ b/src/youtube/signaler.rs @@ -0,0 +1,157 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// /////////////////////////////////////////////// // +// In the realm of YouTube's code, beware, // +// Where chaos reigns, and clarity's rare. // +// Thirty hours deep, I dove with despair, // +// Into the labyrinth of the Signaler lair. // +// // +// Oh, the trials faced, the struggles endured, // +// As I wrestled with quirks, my sanity blurred. // +// In the murky depths of DevTools' lair, // +// I found myself lost, tangled in despair. // +// // +// So heed this warning, brave coder, take care, // +// As you venture forth, through Signaler's snare. // +// My code may be messy, a sight to beware, // +// But through the chaos, a solution may glare. // +// /////////////////////////////////////////////// // + +use std::{collections::HashMap, io::BufRead, iter}; + +use rand::Rng; +use reqwest::{header, Response}; +use simd_json::{ + base::{ValueAsContainer, ValueAsScalar}, + OwnedValue +}; +use url::Url; + +use super::{util::SimdJsonResponseBody, Error}; + +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"; + +#[derive(Debug, Default)] +pub struct SignalerChannelInner { + pub(crate) topic: String, + tango_key: String, + gsessionid: Option, + sid: Option, + rid: usize, + pub(crate) aid: usize, + session_n: usize +} + +impl SignalerChannelInner { + pub fn with_topic(topic: impl ToString, tango_key: impl ToString) -> Self { + Self { + topic: topic.to_string(), + tango_key: tango_key.to_string(), + ..Default::default() + } + } + + pub fn reset(&mut self) { + self.gsessionid = None; + self.sid = None; + self.rid = 0; + self.aid = 0; + self.session_n = 0; + } + + fn gen_zx() -> String { + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + iter::repeat_with(|| CHARSET[rng.gen_range(0..CHARSET.len())] as char).take(11).collect() + } + + pub async fn choose_server(&mut self) -> Result<(), Error> { + let server_response: OwnedValue = super::get_http_client() + .post(Url::parse_with_params(GCM_SIGNALER_SRQE, [("key", &self.tango_key)])?) + .header(header::CONTENT_TYPE, "application/json+protobuf") + .body(format!(r#"[[null,null,null,[7,5],null,[["youtube_live_chat_web"],[1],[[["{}"]]]]]]"#, self.topic)) + .send() + .await? + .simd_json() + .await?; + let gsess = server_response.as_array().unwrap()[0].as_str().unwrap(); + self.gsessionid = Some(gsess.to_owned()); + Ok(()) + } + + pub async fn init_session(&mut self) -> Result<(), Error> { + 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]]]"#, self.topic) + ); + self.session_n = 1; + let ofs = super::get_http_client() + .post(Url::parse_with_params( + GCM_SIGNALER_PSUB, + [ + ("VER", "8"), + ("gsessionid", self.gsessionid.as_ref().unwrap()), + ("key", &self.tango_key), + ("RID", &self.rid.to_string()), + ("AID", &self.aid.to_string()), + ("CVER", "22"), + ("zx", Self::gen_zx().as_ref()), + ("t", "1") + ] + )?) + // yes, this is required. why? who the fuck knows! but if you don't provide this, you get the typical google + // robot error complaining about an invalid request body when you GET GCM_SIGNALER_PSUB. yes, invalid request + // body, in a GET request. where the error actually refers to this POST request. because that makes sense. + .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(); + // first value might be 1 if the request has an error, not entirely sure + assert_eq!(value[0].as_usize().unwrap(), 0); + let sid = value[1].as_array().unwrap()[1].as_str().unwrap(); + self.sid = Some(sid.to_owned()); + Ok(()) + } + + pub async fn get_session_stream(&self) -> Result { + Ok(super::get_http_client() + .get(Url::parse_with_params( + GCM_SIGNALER_PSUB, + [ + ("VER", "8"), + ("gsessionid", self.gsessionid.as_ref().unwrap()), + ("key", &self.tango_key), + ("RID", "rpc"), + ("SID", self.sid.as_ref().unwrap()), + ("AID", &self.aid.to_string()), + ("CI", "0"), + ("TYPE", "xmlhttp"), + ("zx", &Self::gen_zx()), + ("t", "1") + ] + )?) + .header(header::CONNECTION, "keep-alive") + .send() + .await?) + } +} diff --git a/src/youtube/types/get_live_chat.rs b/src/youtube/types/get_live_chat.rs new file mode 100644 index 0000000..566593d --- /dev/null +++ b/src/youtube/types/get_live_chat.rs @@ -0,0 +1,279 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_aux::prelude::*; +use url::Url; + +use super::{deserialize_datetime_utc_from_microseconds, Accessibility, CommandMetadata, Icon, ImageContainer, LocalizedText, UnlocalizedText}; +use crate::youtube::{ + get_http_client, + util::{SimdJsonRequestBody, SimdJsonResponseBody}, + ChatContext, Error, TANGO_LIVE_ENDPOINT, TANGO_REPLAY_ENDPOINT +}; + +#[derive(Serialize, Debug)] +pub struct GetLiveChatRequestBody { + context: GetLiveChatRequestBodyContext, + continuation: String +} + +impl GetLiveChatRequestBody { + pub(crate) fn new(continuation: impl Into, client_version: impl Into, client_name: impl Into) -> Self { + Self { + context: GetLiveChatRequestBodyContext { + client: GetLiveChatRequestBodyContextClient { + client_version: client_version.into(), + client_name: client_name.into() + } + }, + continuation: continuation.into() + } + } +} + +#[derive(Serialize, Debug)] +pub struct GetLiveChatRequestBodyContext { + client: GetLiveChatRequestBodyContextClient +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetLiveChatRequestBodyContextClient { + client_version: String, + client_name: String +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct GetLiveChatResponse { + pub response_context: Option, + pub continuation_contents: GetLiveChatResponseContinuationContents +} + +impl GetLiveChatResponse { + pub async fn fetch(options: &ChatContext, continuation: impl AsRef) -> Result { + let body = GetLiveChatRequestBody::new(continuation.as_ref(), &options.client_version, "WEB"); + Ok(get_http_client() + .post(Url::parse_with_params( + if options.live_status.updates_live() { TANGO_LIVE_ENDPOINT } else { TANGO_REPLAY_ENDPOINT }, + [("key", options.api_key.as_str()), ("prettyPrint", "false")] + )?) + .simd_json(&body)? + .send() + .await? + .simd_json() + .await + .unwrap()) + } +} + +#[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, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ActionContainer { + #[serde(flatten)] + pub action: Action, + pub click_tracking_params: Option +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub enum Continuation { + #[serde(rename = "invalidationContinuationData")] + #[serde(rename_all = "camelCase")] + Invalidation { + invalidation_id: InvalidationId, + timeout_ms: usize, + continuation: String + }, + #[serde(rename = "timedContinuationData")] + #[serde(rename_all = "camelCase")] + Timed { timeout_ms: usize, continuation: String }, + #[serde(rename = "liveChatReplayContinuationData")] + #[serde(rename_all = "camelCase")] + Replay { time_until_last_message_msec: usize, continuation: String }, + #[serde(rename = "playerSeekContinuationData")] + #[serde(rename_all = "camelCase")] + PlayerSeek { 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, Clone)] +#[serde(rename_all = "camelCase")] +pub enum Action { + #[serde(rename = "addChatItemAction")] + #[serde(rename_all = "camelCase")] + AddChatItem { + item: ChatItem, + client_id: Option + }, + #[serde(rename = "removeChatItemAction")] + #[serde(rename_all = "camelCase")] + RemoveChatItem { + target_item_id: String + }, + #[serde(rename = "addLiveChatTickerItemAction")] + #[serde(rename_all = "camelCase")] + AddLiveChatTicker { + item: simd_json::OwnedValue + }, + #[serde(rename = "replayChatItemAction")] + #[serde(rename_all = "camelCase")] + ReplayChat { + actions: Vec, + #[serde(deserialize_with = "deserialize_number_from_string")] + video_offset_time_msec: i64 + }, + LiveChatReportModerationStateCommand(simd_json::OwnedValue) +} + +#[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)] +#[serde(rename_all = "camelCase")] +pub struct MessageRendererBase { + pub author_name: Option, + pub author_photo: ImageContainer, + pub author_badges: Option>, + pub context_menu_endpoint: ContextMenuEndpoint, + pub id: String, + #[serde(deserialize_with = "deserialize_datetime_utc_from_microseconds")] + 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 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 enum ChatItem { + #[serde(rename = "liveChatTextMessageRenderer")] + #[serde(rename_all = "camelCase")] + TextMessage { + #[serde(flatten)] + message_renderer_base: MessageRendererBase, + message: Option + }, + #[serde(rename = "liveChatPaidMessageRenderer")] + #[serde(rename_all = "camelCase")] + Superchat { + #[serde(flatten)] + message_renderer_base: MessageRendererBase, + message: Option, + purchase_amount_text: UnlocalizedText, + header_background_color: isize, + header_text_color: isize, + body_background_color: isize, + body_text_color: isize, + author_name_text_color: isize + }, + #[serde(rename = "liveChatMembershipItemRenderer")] + #[serde(rename_all = "camelCase")] + MembershipItem { + #[serde(flatten)] + message_renderer_base: MessageRendererBase, + header_sub_text: Option, + author_badges: Option> + }, + #[serde(rename = "liveChatPaidStickerRenderer")] + #[serde(rename_all = "camelCase")] + PaidSticker { + #[serde(flatten)] + message_renderer_base: MessageRendererBase, + purchase_amount_text: UnlocalizedText, + sticker: ImageContainer, + money_chip_background_color: isize, + money_chip_text_color: isize, + sticker_display_width: isize, + sticker_display_height: isize, + background_color: isize, + author_name_text_color: isize + }, + #[serde(rename = "liveChatSponsorshipsGiftPurchaseAnnouncementRenderer")] + #[serde(rename_all = "camelCase")] + MembershipGift { + id: String, + #[serde(flatten)] + data: simd_json::OwnedValue + }, + #[serde(rename = "liveChatSponsorshipsGiftRedemptionAnnouncementRenderer")] + #[serde(rename_all = "camelCase")] + MembershipGiftRedemption { + id: String, + #[serde(flatten)] + data: simd_json::OwnedValue + }, + #[serde(rename = "liveChatViewerEngagementMessageRenderer")] + ViewerEngagement { id: String } +} + +impl ChatItem { + pub fn id(&self) -> &str { + match self { + ChatItem::MembershipItem { message_renderer_base, .. } => &message_renderer_base.id, + ChatItem::PaidSticker { message_renderer_base, .. } => &message_renderer_base.id, + ChatItem::Superchat { message_renderer_base, .. } => &message_renderer_base.id, + ChatItem::TextMessage { message_renderer_base, .. } => &message_renderer_base.id, + ChatItem::MembershipGift { id, .. } => id, + ChatItem::MembershipGiftRedemption { id, .. } => id, + ChatItem::ViewerEngagement { id } => id + } + } +} diff --git a/src/youtube/types/mod.rs b/src/youtube/types/mod.rs new file mode 100644 index 0000000..96845e2 --- /dev/null +++ b/src/youtube/types/mod.rs @@ -0,0 +1,121 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::{de::Error, Deserialize, Deserializer}; +use serde_aux::field_attributes::deserialize_number_from_string; +use simd_json::OwnedValue; + +pub mod get_live_chat; +pub mod streams_page; + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CommandMetadata { + pub web_command_metadata: OwnedValue +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UnlocalizedText { + pub simple_text: String, + pub accessibility: Option +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum LocalizedRun { + Text { + text: String + }, + #[serde(rename_all = "camelCase")] + Emoji { + emoji: Emoji, + variant_ids: Option> + } +} + +impl LocalizedRun { + pub fn to_chat_string(&self) -> String { + match self { + Self::Text { text } => text.to_owned(), + Self::Emoji { emoji, .. } => { + if let Some(true) = emoji.is_custom_emoji { + format!(":{}:", emoji.image.accessibility.as_ref().unwrap().accessibility_data.label) + } else { + emoji.image.accessibility.as_ref().unwrap().accessibility_data.label.to_owned() + } + } + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct LocalizedText { + pub runs: Vec +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ImageContainer { + pub thumbnails: Vec, + pub accessibility: Option +} + +#[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 Emoji { + pub emoji_id: String, + pub shortcuts: Option>, + pub search_terms: Option>, + pub supports_skin_tone: Option, + pub image: ImageContainer, + pub is_custom_emoji: Option +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Icon { + pub icon_type: String +} + +pub fn deserialize_datetime_utc_from_microseconds<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de> +{ + use chrono::prelude::*; + + let number = deserialize_number_from_string::(deserializer)?; + let seconds = number / 1_000_000; + let micros = (number % 1_000_000) as u32; + let nanos = micros * 1_000; + + Ok(Utc.from_utc_datetime(&NaiveDateTime::from_timestamp_opt(seconds, nanos).ok_or_else(|| D::Error::custom("Couldn't parse the timestamp"))?)) +} diff --git a/src/youtube/types/streams_page.rs b/src/youtube/types/streams_page.rs new file mode 100644 index 0000000..b0bb631 --- /dev/null +++ b/src/youtube/types/streams_page.rs @@ -0,0 +1,131 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::Deserialize; + +use super::{Accessibility, CommandMetadata, ImageContainer, LocalizedText}; + +#[derive(Debug, Deserialize)] +pub struct YouTubeInitialData { + pub contents: PageContentsRenderer +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PageContentsRenderer { + TwoColumnBrowseResultsRenderer { tabs: Vec } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabItemRenderer { + TabRenderer { + endpoint: FeedEndpoint, + title: String, + #[serde(default)] + selected: bool, + content: Option + }, + ExpandableTabRenderer {} +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedEndpoint { + pub browse_endpoint: BrowseEndpoint, + pub command_metadata: CommandMetadata +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowseEndpoint { + pub browse_id: String, + pub params: String, + pub canonical_base_url: String +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeedContentsRenderer { + RichGridRenderer { contents: Vec, header: FeedHeaderRenderer }, + SectionListRenderer { contents: Vec } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RichGridItem { + #[serde(rename_all = "camelCase")] + RichItemRenderer { content: RichItemContent }, + #[serde(rename_all = "camelCase")] + ContinuationItemRenderer { trigger: ContinuationItemTrigger } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RichItemContent { + #[serde(rename_all = "camelCase")] + VideoRenderer { + description_snippet: LocalizedText, + thumbnail: ImageContainer, + thumbnail_overlays: Vec, + video_id: String + } +} + +#[derive(Debug, Deserialize)] +pub enum ThumbnailOverlay { + #[serde(rename = "thumbnailOverlayTimeStatusRenderer")] + TimeStatus { + style: VideoTimeStatus // text: UnlocalizedText + }, + #[serde(rename = "thumbnailOverlayToggleButtonRenderer")] + #[serde(rename_all = "camelCase")] + ToggleButton { + is_toggled: Option, + toggled_accessibility: Accessibility, + toggled_tooltip: String, + untoggled_accessibility: Accessibility, + untoggled_tooltip: String + }, + #[serde(rename = "thumbnailOverlayNowPlayingRenderer")] + NowPlaying { text: LocalizedText } +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VideoTimeStatus { + Upcoming, + Live, + Default +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ContinuationItemTrigger { + ContinuationTriggerOnItemShown +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeedHeaderRenderer { + #[serde(rename_all = "camelCase")] + FeedFilterChipBarRenderer { contents: Vec, style_type: String } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeedFilterChip { + #[serde(rename_all = "camelCase")] + ChipCloudChipRenderer { is_selected: bool } +} diff --git a/src/youtube/util.rs b/src/youtube/util.rs new file mode 100644 index 0000000..1293102 --- /dev/null +++ b/src/youtube/util.rs @@ -0,0 +1,43 @@ +// Copyright 2024 pyke.io +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::future::Future; + +use reqwest::{RequestBuilder, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +use super::Error; + +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)?)) + } +}