add playlist response mapping

This commit is contained in:
ThetaDev 2022-08-04 13:15:10 +02:00
parent 77675209d5
commit a6041a013b
12 changed files with 42363 additions and 58 deletions

View file

@ -25,8 +25,7 @@ Playlist:
{
context: ...,
# Playlist-ID
browseId: "UCHnyfMqiRRG1u-2MsSQLbXA",
browseId: "VL" + "RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY" (Playlist-ID),
}

View file

@ -14,3 +14,5 @@ Censored: 6SJNVb0GnPI
Geoblocked: sJL6WA-aGkQ (Japan only)
Private: s7_qI6_mIXc
DRM: 1bfOsni7EgI
Album with unknown artists: https://music.youtube.com/playlist?list=OLAK5uy_mEX9ljZeeEWgTM1xLL1isyiGaWXoPyoOk

View file

@ -96,7 +96,7 @@ impl Cache {
{
let mut cache = self.data.lock().await;
if cache.deobf.is_none()
|| cache.deobf.as_ref().unwrap().last_update < Utc::now() - Duration::hours(1)
|| cache.deobf.as_ref().unwrap().last_update < Utc::now() - Duration::hours(24)
{
let deobf_data = updater.await?;
cache.deobf = Some(CacheEntry::from(deobf_data.clone()));

View file

@ -1,4 +1,5 @@
mod player;
pub mod player;
pub mod playlist;
mod response;
use std::sync::Arc;
@ -122,7 +123,7 @@ const TVHTML5_CLIENT_VERSION: &str = "2.0";
const DESKTOP_MUSIC_API_KEY: &str = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20220727.01.00";
const MOBILE_CLIENT_VERSION: &str = "17.10.35";
const MOBILE_CLIENT_VERSION: &str = "17.29.35";
const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
@ -174,7 +175,7 @@ impl RustyTube {
}
}
pub fn get_ytclient(&self, client_type: ClientType) -> Arc<dyn YTClient> {
fn get_ytclient(&self, client_type: ClientType) -> Arc<dyn YTClient> {
match client_type {
ClientType::Desktop => self.desktop_client.clone(),
ClientType::DesktopMusic => self.desktop_music_client.clone(),
@ -205,8 +206,7 @@ pub struct DesktopClient {
locale: Arc<Locale>,
http: Client,
cache: Cache,
consent_cookie_yes: String,
consent_cookie_no: String,
consent_cookie: String,
}
#[async_trait]
@ -246,7 +246,7 @@ impl YTClient for DesktopClient {
)
.header(header::ORIGIN, "https://www.youtube.com")
.header(header::REFERER, "https://www.youtube.com")
.header(header::COOKIE, self.consent_cookie_no.to_owned())
.header(header::COOKIE, self.consent_cookie.to_owned())
.header("X-YouTube-Client-Name", "1")
.header("X-YouTube-Client-Version", self.get_client_version().await)
}
@ -275,18 +275,12 @@ impl DesktopClient {
locale,
http,
cache,
consent_cookie_yes: format!(
consent_cookie: format!(
"{}={}{}",
CONSENT_COOKIE,
CONSENT_COOKIE_YES,
rng.gen_range(100..1000)
),
consent_cookie_no: format!(
"{}={}{}",
CONSENT_COOKIE,
CONSENT_COOKIE_NO,
rng.gen_range(100..1000)
),
}
}
@ -312,7 +306,7 @@ impl DesktopClient {
async fn get_client_version(&self) -> String {
let http = self.http.clone();
let consent_cookie = self.consent_cookie_yes.clone();
let consent_cookie = self.consent_cookie.clone();
let client_data = self
.cache
@ -549,8 +543,7 @@ pub struct DesktopMusicClient {
locale: Arc<Locale>,
http: Client,
cache: Cache,
consent_cookie_yes: String,
consent_cookie_no: String,
consent_cookie: String,
}
#[async_trait]
@ -593,7 +586,7 @@ impl YTClient for DesktopMusicClient {
)
.header(header::ORIGIN, "https://music.youtube.com")
.header(header::REFERER, "https://music.youtube.com")
.header(header::COOKIE, self.consent_cookie_no.to_owned())
.header(header::COOKIE, self.consent_cookie.to_owned())
.header("X-YouTube-Client-Name", "67")
.header("X-YouTube-Client-Version", self.get_client_version().await)
}
@ -622,18 +615,12 @@ impl DesktopMusicClient {
locale,
http,
cache,
consent_cookie_yes: format!(
consent_cookie: format!(
"{}={}{}",
CONSENT_COOKIE,
CONSENT_COOKIE_YES,
rng.gen_range(100..1000)
),
consent_cookie_no: format!(
"{}={}{}",
CONSENT_COOKIE,
CONSENT_COOKIE_NO,
rng.gen_range(100..1000)
),
}
}
@ -659,7 +646,7 @@ impl DesktopMusicClient {
async fn get_client_version(&self) -> String {
let http = self.http.clone();
let consent_cookie = self.consent_cookie_yes.clone();
let consent_cookie = self.consent_cookie.clone();
let client_data = self
.cache
@ -696,7 +683,7 @@ mod tests {
let client = rt.desktop_client;
let version = DesktopClient::extract_client_version_from_swjs(
client.http.clone(),
&client.consent_cookie_yes,
&client.consent_cookie,
)
.await
.unwrap();
@ -719,7 +706,7 @@ mod tests {
let client = rt.desktop_music_client;
let version = DesktopMusicClient::extract_client_version_from_swjs(
client.http.clone(),
&client.consent_cookie_yes,
&client.consent_cookie,
)
.await
.unwrap();

View file

@ -15,7 +15,7 @@ use serde::Serialize;
use url::Url;
use super::{
response::{self, Player},
response,
ClientType, ContextYT, RustyTube, YTClient,
};
use crate::{
@ -64,11 +64,7 @@ struct QContentPlaybackContext {
}
impl RustyTube {
pub async fn fetch_player(
&self,
video_id: &str,
client_type: ClientType,
) -> Result<PlayerData> {
pub async fn get_player(&self, video_id: &str, client_type: ClientType) -> Result<PlayerData> {
let client = self.get_ytclient(client_type);
let (context, deobf) = tokio::join!(
client.get_context(false),
@ -285,7 +281,7 @@ fn cmp_video_streams(a: &VideoStream, b: &VideoStream) -> Ordering {
}
}
fn map_player_data(response: Player, deobf: &Deobfuscator) -> Result<PlayerData> {
fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<PlayerData> {
// Check playability status
match response.playability_status {
response::player::PlayabilityStatus::Ok { live_streamability } => {
@ -417,7 +413,7 @@ fn map_player_data(response: Player, deobf: &Deobfuscator) -> Result<PlayerData>
#[cfg(test)]
mod tests {
use std::{fs, io::Cursor, path::Path};
use std::path::Path;
use crate::{cache::DeobfData, client::CLIENT_TYPES};
@ -460,8 +456,8 @@ mod tests {
let mut json_path = tf_dir.to_path_buf();
json_path.push(format!("{:?}_video.json", client_type).to_lowercase());
let mut file = fs::File::create(json_path).unwrap();
let mut content = Cursor::new(resp.bytes().await.unwrap());
let mut file = std::fs::File::create(json_path).unwrap();
let mut content = std::io::Cursor::new(resp.bytes().await.unwrap());
std::io::copy(&mut content, &mut file).unwrap();
}
}
@ -485,9 +481,9 @@ mod tests {
#[case::android(ClientType::Android)]
#[case::ios(ClientType::Ios)]
#[test_log::test(tokio::test)]
async fn t_fetch_stream(#[case] client_type: ClientType) {
async fn t_get_player(#[case] client_type: ClientType) {
let rt = RustyTube::new();
let player_data = rt.fetch_player("n4tK7LYFxI0", client_type).await.unwrap();
let player_data = rt.get_player("n4tK7LYFxI0", client_type).await.unwrap();
// dbg!(player_data.clone());

116
src/client/playlist.rs Normal file
View file

@ -0,0 +1,116 @@
// REQUEST
use anyhow::{anyhow, bail, Result};
use reqwest::Method;
use serde::Serialize;
use super::{response, ClientType, ContextYT, RustyTube};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QPlaylist {
context: ContextYT,
browse_id: String,
}
impl RustyTube {
pub async fn get_playlist(
&self,
playlist_id: &str,
client_type: ClientType,
) -> Result<response::Playlist> {
// let client = self.desktop_client.clone();
let client = self.get_ytclient(client_type);
let context = client.get_context(true).await;
let request_body = QPlaylist {
context,
browse_id: "VL".to_owned() + playlist_id,
};
let resp = client
.request_builder(Method::POST, "browse")
.await
.json(&request_body)
.send()
.await?
.error_for_status()?;
let playlist_response = resp.json::<response::Playlist>().await?;
Ok(playlist_response)
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::client::ClientType;
use super::*;
#[allow(dead_code)]
// #[test_log::test(tokio::test)]
async fn download_testfiles() {
let tf_dir = Path::new("testfiles/playlist");
let playlist_id = "RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY";
let rt = RustyTube::new();
for client_type in [ClientType::Desktop, ClientType::DesktopMusic] {
let client = rt.get_ytclient(client_type);
let context = client.get_context(false).await;
let request_body = QPlaylist {
context,
browse_id: "VL".to_owned() + playlist_id,
};
let resp = client
.request_builder(Method::POST, "browse")
.await
.json(&request_body)
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let mut json_path = tf_dir.to_path_buf();
json_path.push(format!("{:?}_playlist.json", client_type).to_lowercase());
let mut file = std::fs::File::create(json_path).unwrap();
let mut content = std::io::Cursor::new(resp.bytes().await.unwrap());
std::io::copy(&mut content, &mut file).unwrap();
}
}
#[test_log::test(tokio::test)]
async fn t_get_playlist() {
let rt = RustyTube::new();
let playlist = rt
.get_playlist(
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
ClientType::Desktop,
)
.await
.unwrap();
dbg!(playlist);
}
#[test_log::test(tokio::test)]
async fn t_get_playlist_music() {
let rt = RustyTube::new();
let playlist = rt
.get_playlist(
"RDCLAK5uy_mHW5bcduhjB-PkTePAe6EoRMj1xNT8gzY",
ClientType::DesktopMusic,
)
.await
.unwrap();
dbg!(playlist);
}
}

View file

@ -1,3 +1,74 @@
pub mod player;
pub mod playlist;
pub use player::Player;
pub use playlist::Playlist;
use serde::Deserialize;
use serde_with::{serde_as, VecSkipError};
use crate::serializer::text::TextLink;
#[derive(Default, Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Thumbnails {
pub thumbnails: Vec<Thumbnail>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Thumbnail {
pub url: String,
pub width: u32,
pub height: u32,
}
// YouTube Music
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MusicItem {
thumbnail: MusicThumbnailRenderer,
playlist_item_data: PlaylistItemData,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
flex_columns: Vec<MusicColumn>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
fixed_columns: Vec<MusicColumn>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MusicThumbnailRenderer {
music_thumbnail_renderer: MusicThumbnailRenderer2,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MusicThumbnailRenderer2 {
thumbnail: Thumbnails,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistItemData {
video_id: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct MusicColumn {
#[serde(
rename = "musicResponsiveListItemFlexColumnRenderer",
alias = "musicResponsiveListItemFixedColumnRenderer"
)]
renderer: MusicColumnRenderer,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
pub struct MusicColumnRenderer {
#[serde_as(as = "crate::serializer::text::TextLink")]
text: TextLink,
}

View file

@ -5,6 +5,8 @@ use serde::Deserialize;
use serde_with::serde_as;
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
use super::Thumbnails;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Player {
@ -217,20 +219,6 @@ pub struct VideoDetails {
pub is_live_content: bool,
}
#[derive(Default, Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Thumbnails {
pub thumbnails: Vec<Thumbnail>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Thumbnail {
pub url: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Microformat {

View file

@ -0,0 +1,105 @@
use serde::Deserialize;
use serde_with::serde_as;
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
use crate::serializer::text::TextLink;
use super::{Thumbnails, MusicItem};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Playlist {
pub contents: Contents,
pub header: Header,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contents {
#[serde(alias = "singleColumnBrowseResultsRenderer")]
pub two_column_browse_results_renderer: ContentsRenderer<Tab>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tab {
pub tab_renderer: ContentRenderer<SectionList>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SectionList {
pub section_list_renderer: ContentsRenderer<ItemSection>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemSection {
pub item_section_renderer: Option<ContentsRenderer<PlaylistVideoList>>,
pub music_playlist_shelf_renderer: Option<ContentsRenderer<PlaylistMusicItem>>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistVideoList {
pub playlist_video_list_renderer: ContentsRenderer<PlaylistVideoItem>
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistVideoItem {
playlist_video_renderer: PlaylistVideo,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistMusicItem {
music_responsive_list_item_renderer: MusicItem,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistVideo {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "crate::serializer::text::Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
#[serde_as(as = "crate::serializer::text::TextLink")]
pub channel: TextLink,
#[serde_as(as = "JsonString")]
pub length_seconds: u32,
pub is_playable: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Header {
#[serde(alias = "musicDetailHeaderRenderer")]
pub playlist_header_renderer: HeaderRenderer,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HeaderRenderer {
pub playlist_id: Option<String>,
#[serde_as(as = "crate::serializer::text::Text")]
pub title: String,
#[serde_as(as = "Option<crate::serializer::text::Text>")]
pub description: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentRenderer<T> {
pub content: T
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentsRenderer<T> {
#[serde(alias = "tabs")]
pub contents: Vec<T>
}

View file

@ -1,5 +1,5 @@
use serde::{Deserialize, Deserializer};
use serde_with::{serde_as, DeserializeAs};
use serde_with::{serde_as, DefaultOnError, DeserializeAs};
/// The YouTube API has multiple ways of outputting text. This deserializer
/// is an attempt to unify them.
@ -54,8 +54,147 @@ impl<'de> DeserializeAs<'de, String> for Text {
}
}
#[derive(Debug, Clone)]
pub enum TextLink {
Video {
title: String,
video_id: String,
},
Browse {
text: String,
page_type: PageType,
browse_id: String,
},
None {
text: String,
},
}
#[derive(Deserialize)]
struct TextLinkInternal {
runs: Vec<TextLinkRun>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TextLinkRun {
text: String,
#[serde(default)]
navigation_endpoint: 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")]
command_metadata: Option<CommandMetadata>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WatchEndpoint {
video_id: String,
}
#[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 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, PartialOrd, Ord, Hash)]
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<'de> DeserializeAs<'de, TextLink> for TextLink {
fn deserialize_as<D>(deserializer: D) -> Result<TextLink, D::Error>
where
D: Deserializer<'de>,
{
let link = TextLinkInternal::deserialize(deserializer)?;
if link.runs.len() != 1 {
return Err(serde::de::Error::invalid_length(link.runs.len(), &"1 run"));
}
let text = link.runs[0].text.to_owned();
let nav = &link.runs[0].navigation_endpoint;
Ok(match &nav.watch_endpoint {
Some(w) => TextLink::Video {
title: text,
video_id: w.video_id.to_owned(),
},
None => match &nav.browse_endpoint {
Some(b) => TextLink::Browse {
text,
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 Err(serde::de::Error::custom(
"missing/invalid browse endpoint",
))
}
},
},
browse_id: b.browse_id.to_owned(),
},
None => TextLink::None { text },
},
})
}
}
#[cfg(test)]
mod tests {
use crate::serializer::text::PageType;
use super::TextLink;
use rstest::rstest;
use serde::Deserialize;
use serde_with::serde_as;
@ -95,7 +234,7 @@ mod tests {
}"#,
"Abo für MBCkpop beenden?"
)]
fn t_deserialize(#[case] test_json: &str, #[case] exp: &str) {
fn t_deserialize_text(#[case] test_json: &str, #[case] exp: &str) {
#[serde_as]
#[derive(Deserialize)]
struct S {
@ -106,4 +245,135 @@ mod tests {
let res = serde_json::from_str::<S>(&test_json).unwrap();
assert_eq!(res.txt, exp)
}
#[serde_as]
#[derive(Deserialize)]
struct SLink {
#[serde_as(as = "crate::serializer::text::TextLink")]
ln: TextLink,
}
#[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();
if let TextLink::Video { title, video_id } = res.ln {
assert_eq!(title, "DEEP");
assert_eq!(video_id, "wZIoIgz5mbs");
} else {
panic!("not a video");
}
}
#[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();
if let TextLink::Browse {
text,
page_type,
browse_id,
} = res.ln
{
assert_eq!(text, "DEEP - The 1st Mini Album");
assert_eq!(page_type, PageType::Album);
assert_eq!(browse_id, "MPREb_TKV2ccxsj5i");
} else {
panic!("not a browse item");
}
}
#[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();
if let TextLink::Browse {
text,
page_type,
browse_id,
} = res.ln
{
assert_eq!(text, "laserluca");
assert_eq!(page_type, PageType::Channel);
assert_eq!(browse_id, "UCmxc6kXbU1J-0pR2F3wIx9A");
} else {
panic!("not a browse item");
}
}
#[test]
fn t_link_none() {
let test_json = r#"{
"ln": {
"runs": [
{
"text": "Hello World"
}
]
}
}"#;
let res = serde_json::from_str::<SLink>(&test_json).unwrap();
if let TextLink::None { text } = res.ln {
assert_eq!(text, "Hello World");
} else {
panic!("not none");
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff