1221 lines
47 KiB
Rust
1221 lines
47 KiB
Rust
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<Text>")]
|
||
runs: Vec<String>,
|
||
},
|
||
}
|
||
|
||
impl<'de> DeserializeAs<'de, String> for Text {
|
||
fn deserialize_as<D>(deserializer: D) -> Result<String, D::Error>
|
||
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<String>> for Text {
|
||
fn deserialize_as<D>(deserializer: D) -> Result<Vec<String>, 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<TextComponent>);
|
||
|
||
#[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<RichTextRun>,
|
||
}
|
||
|
||
/// 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<AttributedTextRun>,
|
||
}
|
||
|
||
#[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<WatchEndpoint>,
|
||
#[serde(default)]
|
||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||
browse_endpoint: Option<BrowseEndpoint>,
|
||
#[serde(default)]
|
||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||
url_endpoint: Option<UrlEndpoint>,
|
||
#[serde(default)]
|
||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||
command_metadata: Option<CommandMetadata>,
|
||
}
|
||
|
||
#[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<BrowseEndpointConfig>,
|
||
}
|
||
|
||
#[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<RichTextRun> 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<D>(deserializer: D) -> Result<Self, D::Error>
|
||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||
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<D>(deserializer: D) -> Result<TextComponents, D::Error>
|
||
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<Regex> = 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<TextComponent> for crate::model::ChannelId {
|
||
type Error = MappingError;
|
||
|
||
fn try_from(value: TextComponent) -> Result<Self, Self::Error> {
|
||
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<TextComponent> 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<TextComponents> 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<D>(deserializer: D) -> Result<String, D::Error>
|
||
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<String>,
|
||
}
|
||
|
||
let res_str = serde_json::from_str::<S>(&test_json).unwrap();
|
||
let res_vec = serde_json::from_str::<SVec>(&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::<SLink>(&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::<SLink>(&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::<SLink>(&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::<SLink>(&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::<SLink>(&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::<SLinks>(&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::<SAttributed>(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",
|
||
},
|
||
],
|
||
),
|
||
}
|
||
"###);
|
||
}
|
||
}
|