use std::convert::TryFrom; use fancy_regex::Regex; use once_cell::sync::Lazy; use serde::{Deserialize, Deserializer}; use serde_with::{serde_as, DefaultOnError, DeserializeAs}; use crate::{error::MappingError, util}; /// # Text /// /// The YouTube API has multiple ways of outputting text. This deserializer /// is an attempt to unify them. /// /// ```json /// { /// "text": "Hello World" /// } /// ``` /// /// ```json /// { /// "simpleText": "Hello World" /// } /// ``` /// /// Multiple "runs" aka components of text should be joined together /// ```json /// { /// "runs": [ /// {"text": "Hello"}, /// {"text": " World"}, /// ] /// } /// ``` /// #[serde_as] #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum Text { Simple { #[serde(alias = "simpleText")] text: String, }, Multiple { #[serde_as(as = "Vec")] runs: Vec, }, } impl<'de> DeserializeAs<'de, String> for Text { fn deserialize_as(deserializer: D) -> Result where D: Deserializer<'de>, { let text = Text::deserialize(deserializer)?; match text { Text::Simple { text } => Ok(text), Text::Multiple { runs } => Ok(runs.join("")), } } } impl<'de> DeserializeAs<'de, Vec> for Text { fn deserialize_as(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let text = Text::deserialize(deserializer)?; match text { Text::Simple { text } => Ok(vec![text]), Text::Multiple { runs } => Ok(runs), } } } /// # TextComponent /// /// Some texts on the YouTube website include links. These can be links to /// other YouTube entities (Channels, Videos) as well as websites. /// /// Texts with links are mapped as a list of text components. #[derive(Default, Debug, Clone)] pub struct TextComponents(pub Vec); #[derive(Debug, Clone)] pub enum TextComponent { Video { text: String, video_id: String, start_time: u32, }, Browse { text: String, page_type: PageType, browse_id: String, }, Web { text: String, url: String, }, Text { text: String, }, } /// YouTube's representation of a text with links. It consists of multiple /// runs aka components, which can be simple strings or links. #[derive(Deserialize)] struct RichTextInternal { runs: Vec, } /// TextLinkRun is a single component from a YouTube text with links #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RichTextRun { text: String, #[serde(default)] navigation_endpoint: NavigationEndpoint, } /// This is a new rich text representation format that YouTube is A/B testing /// at the moment. It consists of the full text and an array of ranges describing /// the links. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AttributedText { content: String, #[serde(default)] command_runs: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AttributedTextRun { start_index: usize, length: usize, on_tap: AttributedTextOnTap, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AttributedTextOnTap { innertube_command: NavigationEndpoint, } #[serde_as] #[derive(Deserialize, Default)] #[serde(rename_all = "camelCase")] struct NavigationEndpoint { #[serde(default)] #[serde_as(deserialize_as = "DefaultOnError")] watch_endpoint: Option, #[serde(default)] #[serde_as(deserialize_as = "DefaultOnError")] browse_endpoint: Option, #[serde(default)] #[serde_as(deserialize_as = "DefaultOnError")] url_endpoint: Option, #[serde(default)] #[serde_as(deserialize_as = "DefaultOnError")] command_metadata: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct WatchEndpoint { video_id: String, #[serde(default)] start_time_seconds: u32, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BrowseEndpoint { browse_id: String, browse_endpoint_context_supported_configs: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UrlEndpoint { url: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BrowseEndpointConfig { browse_endpoint_context_music_config: BrowseEndpointMusicConfig, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BrowseEndpointMusicConfig { page_type: PageType, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CommandMetadata { web_command_metadata: WebCommandMetadata, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct WebCommandMetadata { web_page_type: PageType, } #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] pub enum PageType { #[serde(rename = "MUSIC_PAGE_TYPE_ARTIST")] Artist, #[serde(rename = "MUSIC_PAGE_TYPE_ALBUM")] Album, #[serde( rename = "MUSIC_PAGE_TYPE_USER_CHANNEL", alias = "WEB_PAGE_TYPE_CHANNEL" )] Channel, #[serde(rename = "MUSIC_PAGE_TYPE_PLAYLIST", alias = "WEB_PAGE_TYPE_PLAYLIST")] Playlist, } impl From for TextComponent { fn from(run: RichTextRun) -> Self { map_text_component(run.text, run.navigation_endpoint) } } /// Map a single component of a rich text fn map_text_component(text: String, nav: NavigationEndpoint) -> TextComponent { match nav.watch_endpoint { Some(w) => TextComponent::Video { text, video_id: w.video_id, start_time: w.start_time_seconds, }, None => match nav.browse_endpoint { Some(b) => TextComponent::Browse { page_type: match &b.browse_endpoint_context_supported_configs { Some(bc) => bc.browse_endpoint_context_music_config.page_type, None => match &nav.command_metadata { Some(cm) => cm.web_command_metadata.web_page_type, None => return TextComponent::Text { text }, }, }, text, browse_id: b.browse_id, }, None => match nav.url_endpoint { Some(u) => TextComponent::Web { text, url: u.url }, None => TextComponent::Text { text }, }, }, } } impl<'de> Deserialize<'de> for TextComponent { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let mut text = RichTextInternal::deserialize(deserializer)?; if text.runs.len() != 1 { return Err(serde::de::Error::invalid_length( text.runs.len(), &"1 run, use TextComponents for more", )); } Ok(text.runs.swap_remove(0).into()) } } impl<'de> Deserialize<'de> for TextComponents { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let text = RichTextInternal::deserialize(deserializer)?; Ok(Self( text.runs.into_iter().map(TextComponent::from).collect(), )) } } impl<'de> DeserializeAs<'de, TextComponents> for AttributedText { fn deserialize_as(deserializer: D) -> Result where D: Deserializer<'de>, { let text = AttributedText::deserialize(deserializer)?; let mut i_utf16 = 0; let mut chars = text.content.chars(); // Take a string from the char iterator until the given // UTF-16 index. This mimics the Javascript substring behavior. let mut take_chars = |until: usize| { if until <= i_utf16 { return String::new(); } let mut buf = String::with_capacity(until - i_utf16); for c in chars.by_ref() { buf.push(c); // is character on Basic Multilingual Plane -> 16bit in UTF-16, // counts as 1 JS character, otherwise 32bit, counts as 2 JS characters if (c as u32) > 0xffff { i_utf16 += 1; }; i_utf16 += 1; if i_utf16 >= until { break; } } buf }; let mut components = Vec::with_capacity(text.command_runs.len() + 1); text.command_runs.into_iter().for_each(|cmd| { let txt_before = take_chars(cmd.start_index); let txt_link = take_chars(cmd.start_index + cmd.length); // Trim link text: // 3xnbsp, (/ •), nbsp, Name, 2xnbsp // Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}` // Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}` // Replace no-break spaces, trim off whitespace and prefix character let txt_link = txt_link.trim(); let txt_link = txt_link.replace('\u{a0}', " "); static LINK_PREFIX: Lazy = Lazy::new(|| Regex::new("^[/•] *").unwrap()); let txt_link = LINK_PREFIX.replace(&txt_link, ""); if !txt_before.is_empty() { components.push(TextComponent::Text { text: txt_before }); } components.push(map_text_component( txt_link.to_string(), cmd.on_tap.innertube_command, )); }); let end = chars.as_str(); if !end.is_empty() { components.push(TextComponent::Text { text: end.to_owned(), }); } Ok(TextComponents(components)) } } impl TryFrom for crate::model::ChannelId { type Error = MappingError; fn try_from(value: TextComponent) -> Result { match value { TextComponent::Browse { text, page_type, browse_id, } => match page_type { PageType::Channel => Ok(crate::model::ChannelId { id: browse_id, name: text, }), _ => Err(MappingError("invalid channel link type".into())), }, _ => Err(MappingError("invalid channel link".into())), } } } impl From for crate::model::richtext::TextComponent { fn from(component: TextComponent) -> Self { match component { TextComponent::Video { text, video_id, start_time, } => Self::Video { text, id: video_id, start_time, }, TextComponent::Browse { text, page_type, browse_id, } => match page_type { PageType::Artist => Self::Artist { text, id: browse_id, }, PageType::Album => Self::Album { text, id: browse_id, }, PageType::Channel => Self::Channel { text, id: browse_id, }, PageType::Playlist => Self::Playlist { text, id: browse_id, }, }, TextComponent::Web { text, url } => Self::Web { text, url: util::sanitize_yt_url(&url), }, TextComponent::Text { text } => Self::Text(text), } } } impl From for crate::model::richtext::RichText { fn from(components: TextComponents) -> Self { Self(components.0.into_iter().map(TextComponent::into).collect()) } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccessibilityText { accessibility_data: AccessibilityData, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AccessibilityData { label: String, } impl<'de> DeserializeAs<'de, String> for AccessibilityText { fn deserialize_as(deserializer: D) -> Result where D: Deserializer<'de>, { let text = AccessibilityText::deserialize(deserializer)?; Ok(text.accessibility_data.label) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; use serde::Deserialize; use serde_with::serde_as; #[rstest] #[case( r#"{ "txt": { "text": "Hello World" } }"#, vec!["Hello World"] )] #[case( r#"{ "txt": { "simpleText": "Hello World" } }"#, vec!["Hello World"] )] #[case( r#"{ "txt": { "runs": [ { "text": "Abo für " }, { "text": "MBCkpop" }, { "text": " beenden?" } ] } }"#, vec!["Abo für ", "MBCkpop", " beenden?"] )] fn t_deserialize_text(#[case] test_json: &str, #[case] exp: Vec<&str>) { #[serde_as] #[derive(Deserialize)] #[allow(dead_code)] struct S { #[serde_as(as = "Text")] txt: String, } #[serde_as] #[derive(Deserialize)] #[allow(dead_code)] struct SVec { #[serde_as(as = "Text")] txt: Vec, } let res_str = serde_json::from_str::(&test_json).unwrap(); let res_vec = serde_json::from_str::(&test_json).unwrap(); assert_eq!(res_str.txt, exp.join("")); assert_eq!(res_vec.txt, exp); } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SLink { ln: TextComponent, } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SLinks { ln: TextComponents, } #[serde_as] #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SAttributed { #[serde_as(as = "AttributedText")] ln: TextComponents, } #[test] fn t_link_video() { let test_json = r#"{ "ln": { "runs": [ { "text": "DEEP", "navigationEndpoint": { "watchEndpoint": { "videoId": "wZIoIgz5mbs" } } } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLink { ln: Video { text: "DEEP", video_id: "wZIoIgz5mbs", start_time: 0, }, } "###); } #[test] fn t_link_album() { let test_json = r#"{ "ln": { "runs": [ { "text": "DEEP - The 1st Mini Album", "navigationEndpoint": { "browseEndpoint": { "browseId": "MPREb_TKV2ccxsj5i", "browseEndpointContextSupportedConfigs": { "browseEndpointContextMusicConfig": { "pageType": "MUSIC_PAGE_TYPE_ALBUM" } } } } } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLink { ln: Browse { text: "DEEP - The 1st Mini Album", page_type: Album, browse_id: "MPREb_TKV2ccxsj5i", }, } "###); } #[test] fn t_link_channel() { let test_json = r#"{ "ln": { "runs": [ { "text": "laserluca", "navigationEndpoint": { "commandMetadata": { "webCommandMetadata": { "webPageType": "WEB_PAGE_TYPE_CHANNEL" } }, "browseEndpoint": { "browseId": "UCmxc6kXbU1J-0pR2F3wIx9A" } } } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLink { ln: Browse { text: "laserluca", page_type: Channel, browse_id: "UCmxc6kXbU1J-0pR2F3wIx9A", }, } "###); } #[test] fn t_link_none() { let test_json = r#"{ "ln": { "runs": [ { "text": "Hello World" } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLink { ln: Text { text: "Hello World", }, } "###); } #[test] fn t_link_web() { let test_json = r#"{ "ln": { "runs": [ { "text": "Creative Commons", "navigationEndpoint": { "clickTrackingParams": "CJsBEM2rARgBIhMImKz9y6Oc-QIVTJpVCh3VrAYM", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/t/creative_commons", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/t/creative_commons" } } } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLink { ln: Web { text: "Creative Commons", url: "https://www.youtube.com/t/creative_commons", }, } "###); } #[test] fn t_links_artists() { let test_json = r#"{ "ln": { "runs": [ { "text": "Roland Kaiser", "navigationEndpoint": { "clickTrackingParams": "CNAMEMn0AhgFIhMI3aq914Tn-QIVi9ARCB3w6w_p", "browseEndpoint": { "browseId": "UCtqi0viP-suK-okUQfaw8Ew", "browseEndpointContextSupportedConfigs": { "browseEndpointContextMusicConfig": { "pageType": "MUSIC_PAGE_TYPE_ARTIST" } } } } }, { "text": " & " }, { "text": "Maite Kelly", "navigationEndpoint": { "clickTrackingParams": "CNAMEMn0AhgFIhMI3aq914Tn-QIVi9ARCB3w6w_p", "browseEndpoint": { "browseId": "UCY06CayCwdaOd1CnDgjy6uw", "browseEndpointContextSupportedConfigs": { "browseEndpointContextMusicConfig": { "pageType": "MUSIC_PAGE_TYPE_ARTIST" } } } } } ] } }"#; let res = serde_json::from_str::(&test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SLinks { ln: TextComponents( [ Browse { text: "Roland Kaiser", page_type: Artist, browse_id: "UCtqi0viP-suK-okUQfaw8Ew", }, Text { text: " & ", }, Browse { text: "Maite Kelly", page_type: Artist, browse_id: "UCY06CayCwdaOd1CnDgjy6uw", }, ], ), } "###); } #[test] fn t_attributed_description() { let test_json = r#"{ "ln": { "content": "🎧Listen and download aespa's debut single \"Black Mamba\": https://smarturl.it/aespa_BlackMamba\n🐍The Debut Stage    • aespa 에스파 'Black ...  \n\n🎟️ aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A...\n\nSubscribe to aespa Official YouTube Channel!\nhttps://www.youtube.com/aespa?sub_con...\n\naespa official\n   / aespa  \nhttps://www.instagram.com/aespa_official\nhttps://www.tiktok.com/@aespa_official\nhttps://twitter.com/aespa_Official\nhttps://www.facebook.com/aespa.official\nhttps://weibo.com/aespa\n\n#aespa #æspa #BlackMamba #블랙맘바 #에스파\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment", "commandRuns": [ { "startIndex": 58, "length": 36, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbm1qRVVfQUlObURLcnFFQXBTUkJSOEpqWGIzUXxBQ3Jtc0tsNUJIYm5xdERxZk9rZEw3YlJzV0ZIYTNaSjU2a21PaFhNUmxzdjI5VE1VRWUyczZwYmtmQXh3QXV0eXlkMDgxRUJoNVMzRFZ6RlZ6MGdXeXdWQXFTTGY2ZHhFcUFqdExRQ21PYzNfWmlBaHhqYXVUdw&q=https%3A%2F%2Fsmarturl.it%2Faespa_BlackMamba&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbm1qRVVfQUlObURLcnFFQXBTUkJSOEpqWGIzUXxBQ3Jtc0tsNUJIYm5xdERxZk9rZEw3YlJzV0ZIYTNaSjU2a21PaFhNUmxzdjI5VE1VRWUyczZwYmtmQXh3QXV0eXlkMDgxRUJoNVMzRFZ6RlZ6MGdXeXdWQXFTTGY2ZHhFcUFqdExRQ21PYzNfWmlBaHhqYXVUdw&q=https%3A%2F%2Fsmarturl.it%2Faespa_BlackMamba&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 113, "length": 27, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/watch?v=Ky5RT5oGg0w&t=0s", "webPageType": "WEB_PAGE_TYPE_WATCH", "rootVe": 3832 } }, "watchEndpoint": { "videoId": "Ky5RT5oGg0w", "startTimeSeconds": 0, "watchEndpointSupportedOnesieConfig": { "html5PlaybackOnesieConfig": { "commonConfig": { "url": "https://rr5---sn-h0jeener.googlevideo.com/initplayback?source=youtube&orc=1&oeis=1&c=WEB&oad=3200&ovd=3200&oaad=11000&oavd=11000&ocs=700&oewis=1&oputc=1&ofpcc=1&msp=1&odeak=1&odepv=1&osfc=1&id=2b2e514f9a06834c&ip=2003%3Ade%3Aaf30%3A200%3Ad8ce%3A4044%3A2ba2%3A3881&initcwndbps=1556250&mt=1663992556&oweuc=" } } } } } } }, { "startIndex": 194, "length": 40, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbU1ObGNaRDZaRmo1X1ZjejBoeTRnWkxuVUJxZ3xBQ3Jtc0ttWk1BVVhaRXRfN1VYWXBqMHdaYURTRFJNcUZJVlY3a21wRHE2ZGZaclE3WUM5bEZWbmFfT0sxWTZHOVotWVh6U3YtVk94SlA5NkRFTnBPcHVCWDJhMGJRQlI3ZHN0MnJleHp0c2lEVWNxeW1jSDZuVQ&q=https%3A%2F%2Fwww.ticketmaster.com%2Fevent%2F0A005CCD9E871F6E&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbU1ObGNaRDZaRmo1X1ZjejBoeTRnWkxuVUJxZ3xBQ3Jtc0ttWk1BVVhaRXRfN1VYWXBqMHdaYURTRFJNcUZJVlY3a21wRHE2ZGZaclE3WUM5bEZWbmFfT0sxWTZHOVotWVh6U3YtVk94SlA5NkRFTnBPcHVCWDJhMGJRQlI3ZHN0MnJleHp0c2lEVWNxeW1jSDZuVQ&q=https%3A%2F%2Fwww.ticketmaster.com%2Fevent%2F0A005CCD9E871F6E&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 281, "length": 40, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/aespa?sub_confirmation=1", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/aespa?sub_confirmation=1", "nofollow": true } } } }, { "startIndex": 338, "length": 12, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/c/aespa", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/c/aespa", "nofollow": true } } } }, { "startIndex": 351, "length": 40, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbE9FVEtZZkVLUExjdFBnZjZnZ19KNWRYOVZUd3xBQ3Jtc0tsbHpCa1hLTVJ6MEllczlzUEpoVi1IQ2F5NG1jMnlOT3p3bnlFeE80ZzlsaG5CUXlFQnFGTkMtN19DcVYzQkw3bVlVVmNwQlpYQWZnNGNsME45WE1WQ21sR3V1Z3k5RG9DUDE0VTZQTm53Mk9vTWhiOA&q=https%3A%2F%2Fwww.instagram.com%2Faespa_official&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbE9FVEtZZkVLUExjdFBnZjZnZ19KNWRYOVZUd3xBQ3Jtc0tsbHpCa1hLTVJ6MEllczlzUEpoVi1IQ2F5NG1jMnlOT3p3bnlFeE80ZzlsaG5CUXlFQnFGTkMtN19DcVYzQkw3bVlVVmNwQlpYQWZnNGNsME45WE1WQ21sR3V1Z3k5RG9DUDE0VTZQTm53Mk9vTWhiOA&q=https%3A%2F%2Fwww.instagram.com%2Faespa_official&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 392, "length": 38, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbVdlSGk3eDd5U0dUVG16VFJCQnhKVFBEUUxMQXxBQ3Jtc0tuX3ZJbENNY1ZSN0FFemdxTFdlcTVvc3AwZE05NEFvRW5nOHpZWDUtZG9ORHBnT1JGc2UySDh3WWl3MU53VjFvbHRSdjdxMUlGM2Z6SmdaLTVaWWxhamJEems0Uld3MGlTT0Z0bkh5Y0hpcnY1aXptSQ&q=https%3A%2F%2Fwww.tiktok.com%2F%40aespa_official&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbVdlSGk3eDd5U0dUVG16VFJCQnhKVFBEUUxMQXxBQ3Jtc0tuX3ZJbENNY1ZSN0FFemdxTFdlcTVvc3AwZE05NEFvRW5nOHpZWDUtZG9ORHBnT1JGc2UySDh3WWl3MU53VjFvbHRSdjdxMUlGM2Z6SmdaLTVaWWxhamJEems0Uld3MGlTT0Z0bkh5Y0hpcnY1aXptSQ&q=https%3A%2F%2Fwww.tiktok.com%2F%40aespa_official&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 431, "length": 34, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqa3lNcG1lMHkwSzNLQVBrUXFNTXl0N1hNa04wUXxBQ3Jtc0tubm1sQkdaVjNYR04xOHpJV3NxZVBpb3I5V1FVOHVFNC1uWE5vb211ZmZKYzhTZXZfbjlkY09fanBRdHpjUkdRVGJJYS0xZ3NBNkVZQVhWSS0xVDYwRlRzQ0J3ODQxNDE0ODAxd1Q0cG5icVlNWndscw&q=https%3A%2F%2Ftwitter.com%2Faespa_Official&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqa3lNcG1lMHkwSzNLQVBrUXFNTXl0N1hNa04wUXxBQ3Jtc0tubm1sQkdaVjNYR04xOHpJV3NxZVBpb3I5V1FVOHVFNC1uWE5vb211ZmZKYzhTZXZfbjlkY09fanBRdHpjUkdRVGJJYS0xZ3NBNkVZQVhWSS0xVDYwRlRzQ0J3ODQxNDE0ODAxd1Q0cG5icVlNWndscw&q=https%3A%2F%2Ftwitter.com%2Faespa_Official&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 466, "length": 39, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbjdBNG5yVEFwU0JMNGZaLUpQZ1ZoeGgwT0xOZ3xBQ3Jtc0tuRFdFNlJNV29PMThRNWo5MHZrREZ1ZU5oZlkxVmE4ZlU5STFCZW1mUFVSdXJ3VUQxUnNVVkUzLWJQMS1uRzVjdkRCV2ZxSWJ6cFNxRVVzejY0SDltZFZPc2xwS3ZPZGIxcFZ6cndIVkMtUjVtZ054cw&q=https%3A%2F%2Fwww.facebook.com%2Faespa.official&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbjdBNG5yVEFwU0JMNGZaLUpQZ1ZoeGgwT0xOZ3xBQ3Jtc0tuRFdFNlJNV29PMThRNWo5MHZrREZ1ZU5oZlkxVmE4ZlU5STFCZW1mUFVSdXJ3VUQxUnNVVkUzLWJQMS1uRzVjdkRCV2ZxSWJ6cFNxRVVzejY0SDltZFZPc2xwS3ZPZGIxcFZ6cndIVkMtUjVtZ054cw&q=https%3A%2F%2Fwww.facebook.com%2Faespa.official&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 506, "length": 23, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ0BEM2rARgBIhMIzvHr0sis-gIV0kZ6BR0GNA_4SJGXrtzn9erzZQ==", "commandMetadata": { "webCommandMetadata": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbEtGMHB6eXBESW92aEVLc1FybkRwQU95eTh6UXxBQ3Jtc0tuWXc5d2JsTHFYcHExdy1FTDFyUV9wdU1DSmxELUxGSGlPMzhBdFVkblRSZkNLQzRaMEJGUGhYLWp4RU40YUVwV3N3ZUpRTVVKVDRiY19zeE5RUkt2dW5aUVcxcHBRQldCOTE3YktXSXZlSFJhRWRjdw&q=https%3A%2F%2Fweibo.com%2Faespa&v=ZeerrnuLi5E", "webPageType": "WEB_PAGE_TYPE_UNKNOWN", "rootVe": 83769 } }, "urlEndpoint": { "url": "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbEtGMHB6eXBESW92aEVLc1FybkRwQU95eTh6UXxBQ3Jtc0tuWXc5d2JsTHFYcHExdy1FTDFyUV9wdU1DSmxELUxGSGlPMzhBdFVkblRSZkNLQzRaMEJGUGhYLWp4RU40YUVwV3N3ZUpRTVVKVDRiY19zeE5RUkt2dW5aUVcxcHBRQldCOTE3YktXSXZlSFJhRWRjdw&q=https%3A%2F%2Fweibo.com%2Faespa&v=ZeerrnuLi5E", "target": "TARGET_NEW_WINDOW", "nofollow": true } } } }, { "startIndex": 531, "length": 6, "onTap": { "innertubeCommand": { "clickTrackingParams": "CKIBENzXBBgKIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/hashtag/aespa", "webPageType": "WEB_PAGE_TYPE_BROWSE", "rootVe": 6827, "apiUrl": "/youtubei/v1/browse" } }, "browseEndpoint": { "browseId": "FEhashtag", "params": "6gUHCgVhZXNwYQ%3D%3D" } } }, "loggingDirectives": { "trackingParams": "CKIBENzXBBgKIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "enableDisplayloggerExperiment": true } }, { "startIndex": 538, "length": 5, "onTap": { "innertubeCommand": { "clickTrackingParams": "CKEBENzXBBgLIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/hashtag/%C3%A6spa", "webPageType": "WEB_PAGE_TYPE_BROWSE", "rootVe": 6827, "apiUrl": "/youtubei/v1/browse" } }, "browseEndpoint": { "browseId": "FEhashtag", "params": "6gUHCgXDpnNwYQ%3D%3D" } } }, "loggingDirectives": { "trackingParams": "CKEBENzXBBgLIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "enableDisplayloggerExperiment": true } }, { "startIndex": 544, "length": 11, "onTap": { "innertubeCommand": { "clickTrackingParams": "CKABENzXBBgMIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/hashtag/blackmamba", "webPageType": "WEB_PAGE_TYPE_BROWSE", "rootVe": 6827, "apiUrl": "/youtubei/v1/browse" } }, "browseEndpoint": { "browseId": "FEhashtag", "params": "6gUMCgpibGFja21hbWJh" } } }, "loggingDirectives": { "trackingParams": "CKABENzXBBgMIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "enableDisplayloggerExperiment": true } }, { "startIndex": 556, "length": 5, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ8BENzXBBgNIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/hashtag/%EB%B8%94%EB%9E%99%EB%A7%98%EB%B0%94", "webPageType": "WEB_PAGE_TYPE_BROWSE", "rootVe": 6827, "apiUrl": "/youtubei/v1/browse" } }, "browseEndpoint": { "browseId": "FEhashtag", "params": "6gUOCgzruJTrnpnrp5jrsJQ%3D" } } }, "loggingDirectives": { "trackingParams": "CJ8BENzXBBgNIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "enableDisplayloggerExperiment": true } }, { "startIndex": 562, "length": 4, "onTap": { "innertubeCommand": { "clickTrackingParams": "CJ4BENzXBBgOIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "commandMetadata": { "webCommandMetadata": { "url": "/hashtag/%EC%97%90%EC%8A%A4%ED%8C%8C", "webPageType": "WEB_PAGE_TYPE_BROWSE", "rootVe": 6827, "apiUrl": "/youtubei/v1/browse" } }, "browseEndpoint": { "browseId": "FEhashtag", "params": "6gULCgnsl5DsiqTtjIw%3D" } } }, "loggingDirectives": { "trackingParams": "CJ4BENzXBBgOIhMIzvHr0sis-gIV0kZ6BR0GNA_4", "enableDisplayloggerExperiment": true } } ] } }"#; let res = serde_json::from_str::(test_json).unwrap(); insta::assert_debug_snapshot!(res, @r###" SAttributed { ln: TextComponents( [ Text { text: "🎧Listen and download aespa's debut single \"Black Mamba\": ", }, Web { text: "https://smarturl.it/aespa_BlackMamba", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbm1qRVVfQUlObURLcnFFQXBTUkJSOEpqWGIzUXxBQ3Jtc0tsNUJIYm5xdERxZk9rZEw3YlJzV0ZIYTNaSjU2a21PaFhNUmxzdjI5VE1VRWUyczZwYmtmQXh3QXV0eXlkMDgxRUJoNVMzRFZ6RlZ6MGdXeXdWQXFTTGY2ZHhFcUFqdExRQ21PYzNfWmlBaHhqYXVUdw&q=https%3A%2F%2Fsmarturl.it%2Faespa_BlackMamba&v=ZeerrnuLi5E", }, Text { text: "\n🐍The Debut Stage ", }, Video { text: "aespa 에스파 'Black ...", video_id: "Ky5RT5oGg0w", start_time: 0, }, Text { text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ", }, Web { text: "https://www.ticketmaster.com/event/0A...", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbU1ObGNaRDZaRmo1X1ZjejBoeTRnWkxuVUJxZ3xBQ3Jtc0ttWk1BVVhaRXRfN1VYWXBqMHdaYURTRFJNcUZJVlY3a21wRHE2ZGZaclE3WUM5bEZWbmFfT0sxWTZHOVotWVh6U3YtVk94SlA5NkRFTnBPcHVCWDJhMGJRQlI3ZHN0MnJleHp0c2lEVWNxeW1jSDZuVQ&q=https%3A%2F%2Fwww.ticketmaster.com%2Fevent%2F0A005CCD9E871F6E&v=ZeerrnuLi5E", }, Text { text: "\n\nSubscribe to aespa Official YouTube Channel!\n", }, Web { text: "https://www.youtube.com/aespa?sub_con...", url: "https://www.youtube.com/aespa?sub_confirmation=1", }, Text { text: "\n\naespa official\n", }, Web { text: "aespa", url: "https://www.youtube.com/c/aespa", }, Text { text: "\n", }, Web { text: "https://www.instagram.com/aespa_official", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbE9FVEtZZkVLUExjdFBnZjZnZ19KNWRYOVZUd3xBQ3Jtc0tsbHpCa1hLTVJ6MEllczlzUEpoVi1IQ2F5NG1jMnlOT3p3bnlFeE80ZzlsaG5CUXlFQnFGTkMtN19DcVYzQkw3bVlVVmNwQlpYQWZnNGNsME45WE1WQ21sR3V1Z3k5RG9DUDE0VTZQTm53Mk9vTWhiOA&q=https%3A%2F%2Fwww.instagram.com%2Faespa_official&v=ZeerrnuLi5E", }, Text { text: "\n", }, Web { text: "https://www.tiktok.com/@aespa_official", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbVdlSGk3eDd5U0dUVG16VFJCQnhKVFBEUUxMQXxBQ3Jtc0tuX3ZJbENNY1ZSN0FFemdxTFdlcTVvc3AwZE05NEFvRW5nOHpZWDUtZG9ORHBnT1JGc2UySDh3WWl3MU53VjFvbHRSdjdxMUlGM2Z6SmdaLTVaWWxhamJEems0Uld3MGlTT0Z0bkh5Y0hpcnY1aXptSQ&q=https%3A%2F%2Fwww.tiktok.com%2F%40aespa_official&v=ZeerrnuLi5E", }, Text { text: "\n", }, Web { text: "https://twitter.com/aespa_Official", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqa3lNcG1lMHkwSzNLQVBrUXFNTXl0N1hNa04wUXxBQ3Jtc0tubm1sQkdaVjNYR04xOHpJV3NxZVBpb3I5V1FVOHVFNC1uWE5vb211ZmZKYzhTZXZfbjlkY09fanBRdHpjUkdRVGJJYS0xZ3NBNkVZQVhWSS0xVDYwRlRzQ0J3ODQxNDE0ODAxd1Q0cG5icVlNWndscw&q=https%3A%2F%2Ftwitter.com%2Faespa_Official&v=ZeerrnuLi5E", }, Text { text: "\n", }, Web { text: "https://www.facebook.com/aespa.official", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbjdBNG5yVEFwU0JMNGZaLUpQZ1ZoeGgwT0xOZ3xBQ3Jtc0tuRFdFNlJNV29PMThRNWo5MHZrREZ1ZU5oZlkxVmE4ZlU5STFCZW1mUFVSdXJ3VUQxUnNVVkUzLWJQMS1uRzVjdkRCV2ZxSWJ6cFNxRVVzejY0SDltZFZPc2xwS3ZPZGIxcFZ6cndIVkMtUjVtZ054cw&q=https%3A%2F%2Fwww.facebook.com%2Faespa.official&v=ZeerrnuLi5E", }, Text { text: "\n", }, Web { text: "https://weibo.com/aespa", url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbEtGMHB6eXBESW92aEVLc1FybkRwQU95eTh6UXxBQ3Jtc0tuWXc5d2JsTHFYcHExdy1FTDFyUV9wdU1DSmxELUxGSGlPMzhBdFVkblRSZkNLQzRaMEJGUGhYLWp4RU40YUVwV3N3ZUpRTVVKVDRiY19zeE5RUkt2dW5aUVcxcHBRQldCOTE3YktXSXZlSFJhRWRjdw&q=https%3A%2F%2Fweibo.com%2Faespa&v=ZeerrnuLi5E", }, Text { text: "\n\n", }, Text { text: "#aespa", }, Text { text: " ", }, Text { text: "#æspa", }, Text { text: " ", }, Text { text: "#BlackMamba", }, Text { text: " ", }, Text { text: "#블랙맘바", }, Text { text: " ", }, Text { text: "#에스파", }, Text { text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment", }, ], ), } "###); } }