fix: a/b test 10: channel about modal

This commit is contained in:
ThetaDev 2023-11-03 21:46:55 +01:00
parent cced125390
commit ba06e2c8c8
17 changed files with 1686 additions and 2932 deletions

View file

@ -26,6 +26,7 @@ pub enum ABTest {
ShortDateFormat = 7,
TrackViewcount = 8,
PlaylistsForShorts = 9,
ChannelAboutModal = 10,
}
const TESTS_TO_RUN: [ABTest; 3] = [
@ -98,6 +99,7 @@ pub async fn run_test(
ABTest::ShortDateFormat => short_date_format(&query).await,
ABTest::PlaylistsForShorts => playlists_for_shorts(&query).await,
ABTest::TrackViewcount => track_viewcount(&query).await,
ABTest::ChannelAboutModal => channel_about_modal(&query).await,
}
.unwrap();
pb.inc(1);
@ -259,6 +261,16 @@ pub async fn short_date_format(rp: &RustyPipeQuery) -> Result<bool> {
}))
}
pub async fn playlists_for_shorts(rp: &RustyPipeQuery) -> Result<bool> {
let playlist = rp.playlist("UUSHh8gHdtzO2tXd593_bjErWg").await?;
let v1 = playlist
.videos
.items
.first()
.ok_or_else(|| anyhow::anyhow!("no videos"))?;
Ok(v1.publish_date_txt.is_none())
}
pub async fn track_viewcount(rp: &RustyPipeQuery) -> Result<bool> {
let res = rp.music_search("lieblingsmensch namika").await?;
@ -273,12 +285,19 @@ pub async fn track_viewcount(rp: &RustyPipeQuery) -> Result<bool> {
Ok(track.view_count.is_some())
}
pub async fn playlists_for_shorts(rp: &RustyPipeQuery) -> Result<bool> {
let playlist = rp.playlist("UUSHh8gHdtzO2tXd593_bjErWg").await?;
let v1 = playlist
.videos
.items
.first()
.ok_or_else(|| anyhow::anyhow!("no videos"))?;
Ok(v1.publish_date_txt.is_none())
pub async fn channel_about_modal(rp: &RustyPipeQuery) -> Result<bool> {
let id = "UC2DjFE7Xf11URZqWBigcVOQ";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
context: rp.get_context(ClientType::Desktop, true, None).await,
browse_id: id,
params: None,
},
)
.await
.unwrap();
Ok(!res.contains("\"EgVhYm91dPIGBAoCEgA%3D\""))
}

View file

@ -339,7 +339,7 @@ async fn channel_playlists() {
}
async fn channel_info() {
let json_path = path!(*TESTFILES_DIR / "channel" / "channel_info.json");
let json_path = path!(*TESTFILES_DIR / "channel" / "channel_info2.json");
if json_path.exists() {
return;
}

View file

@ -202,11 +202,20 @@ pub enum Country {
.to_owned();
let mut code_lang_array = format!(
"/// Array of all available languages\npub const LANGUAGES: [Language; {}] = [\n",
r#"/// Array of all available languages
/// The languages are sorted by their native names. This array can be used to display
/// a language selection or to get the language code from a language name using binary search.
pub const LANGUAGES: [Language; {}] = [
"#,
languages.len()
);
let mut code_country_array = format!(
"/// Array of all available countries\npub const COUNTRIES: [Country; {}] = [\n",
r#"/// Array of all available countries
///
/// The countries are sorted by their english names. This array can be used to display
/// a country selection or to get the country code from a country name using binary search.
pub const COUNTRIES: [Country; {}] = [
"#,
countries.len()
);
@ -252,9 +261,6 @@ pub enum Country {
code_langs += &enum_name;
code_langs += ",\n";
// Language array
writeln!(code_lang_array, " Language::{enum_name},").unwrap();
// Language names
writeln!(
code_lang_names,
@ -264,6 +270,24 @@ pub enum Country {
}
code_langs += "}\n";
// Language array
let languages_by_name = languages
.iter()
.map(|(k, v)| (v, k))
.collect::<BTreeMap<_, _>>();
for code in languages_by_name.values() {
let enum_name = code.split('-').fold(String::new(), |mut output, c| {
let _ = write!(
output,
"{}{}",
c[0..1].to_owned().to_uppercase(),
c[1..].to_owned().to_lowercase()
);
output
});
writeln!(code_lang_array, " Language::{enum_name},").unwrap();
}
for (c, n) in &countries {
let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase();
@ -271,9 +295,6 @@ pub enum Country {
writeln!(code_countries, " /// {n}").unwrap();
writeln!(code_countries, " {enum_name},").unwrap();
// Country array
writeln!(code_country_array, " Country::{enum_name},").unwrap();
// Country names
writeln!(
code_country_names,
@ -282,6 +303,16 @@ pub enum Country {
.unwrap();
}
// Country array
let countries_by_name = countries
.iter()
.map(|(k, v)| (v, k))
.collect::<BTreeMap<_, _>>();
for c in countries_by_name.values() {
let enum_name = c[0..1].to_owned().to_uppercase() + &c[1..].to_owned().to_lowercase();
writeln!(code_country_array, " Country::{enum_name},").unwrap();
}
// Add Country::Zz / Global
code_countries += " /// Global (can only be used for music charts)\n";
code_countries += " Zz,\n";

View file

@ -417,3 +417,18 @@ tab.
Since the reel items dont include upload date information you can circumvent this new UI
by using the mobile client. But that may change in the future.
## [10] Channel About modal
- **Encountered on:** 03.11.2023
- **Impact:** 🟡 Medium
- **Endpoint:** browse (channel info)
![A/B test 10 screenshot](./_img/ab_10.png)
YouTube replaced the *About* channel tab with a modal. This changes the way additional
channel metadata has to be fetched.
The new modal uses a continuation request with a token which can be easily generated.
Attempts to fetch the old about tab with the A/B test enabled will lead to a redirect to
the main tab.

BIN
notes/_img/ab_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,20 +1,21 @@
use std::fmt::Debug;
use serde::Serialize;
use time::OffsetDateTime;
use url::Url;
use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
Channel, ChannelInfo, PlaylistItem, VideoItem, YouTubeItem,
Channel, ChannelInfo, PlaylistItem, VideoItem,
},
param::{ChannelOrder, ChannelVideoTab, Language},
serializer::MapResult,
util::{self, ProtoBuilder},
serializer::{text::TextComponent, MapResult},
util::{self, timeago, ProtoBuilder},
};
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
use super::{response, ClientType, MapResponse, QContinuation, RustyPipeQuery, YTContext};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@ -36,8 +37,6 @@ enum ChannelTab {
Live,
#[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")]
Playlists,
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
Info,
#[serde(rename = "EgZzZWFyY2jyBgQKAloA")]
Search,
}
@ -126,7 +125,7 @@ impl RustyPipeQuery {
let visitor_data = Some(self.get_visitor_data().await?);
self.continuation(
order_ctoken(channel_id.as_ref(), tab, order),
order_ctoken(channel_id.as_ref(), tab, order, &random_target()),
ContinuationEndpoint::Browse,
visitor_data.as_deref(),
)
@ -179,19 +178,17 @@ impl RustyPipeQuery {
pub async fn channel_info<S: AsRef<str> + Debug>(
&self,
channel_id: S,
) -> Result<Channel<ChannelInfo>, Error> {
) -> Result<ChannelInfo, Error> {
let channel_id = channel_id.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QChannel {
let context = self.get_context(ClientType::Desktop, false, None).await;
let request_body = QContinuation {
context,
browse_id: channel_id,
params: ChannelTab::Info,
query: None,
continuation: &channel_info_ctoken(channel_id, &random_target()),
};
self.execute_request::<response::Channel, _, _>(
self.execute_request::<response::ChannelAbout, _, _>(
ClientType::Desktop,
"channel_info",
"channel_info2",
channel_id,
"browse",
&request_body,
@ -290,46 +287,64 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
}
}
impl MapResponse<Channel<ChannelInfo>> for response::Channel {
impl MapResponse<ChannelInfo> for response::ChannelAbout {
fn map_response(
self,
id: &str,
_id: &str,
lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
vdata: Option<&str>,
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
let content = map_channel_content(id, self.contents, self.alerts)?;
let channel_data = map_channel(
MapChannelData {
header: self.header,
metadata: self.metadata,
microformat: self.microformat,
visitor_data: self
.response_context
.visitor_data
.or_else(|| vdata.map(str::to_owned)),
has_shorts: content.has_shorts,
has_live: content.has_live,
},
id,
lang,
)?;
_visitor_data: Option<&str>,
) -> Result<MapResult<ChannelInfo>, ExtractionError> {
let ep = self
.on_response_received_endpoints
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?;
let continuations = ep.append_continuation_items_action.continuation_items;
let about = continuations
.c
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no aboutChannel data".into()))?
.about_channel_renderer
.metadata
.about_channel_view_model;
let mut warnings = continuations.warnings;
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(lang);
mapper.map_response(content.content);
let mut warnings = mapper.warnings;
let cinfo = mapper.channel_info.unwrap_or_else(|| {
warnings.push("no aboutFullMetadata".to_owned());
ChannelInfo {
create_date: None,
view_count: None,
links: Vec::new(),
let links = about
.links
.into_iter()
.filter_map(|l| {
let lv = l.channel_external_link_view_model;
if let TextComponent::Web { url, .. } = lv.link {
Some((String::from(lv.title), util::sanitize_yt_url(&url)))
} else {
None
}
});
})
.collect::<Vec<_>>();
Ok(MapResult {
c: combine_channel_data(channel_data.c, cinfo),
c: ChannelInfo {
id: about.channel_id,
url: about.canonical_channel_url,
description: about.description,
subscriber_count: about
.subscriber_count_text
.and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut warnings)),
video_count: about
.video_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
create_date: about.joined_date_text.and_then(|txt| {
timeago::parse_textual_date_or_warn(lang, &txt, &mut warnings)
.map(OffsetDateTime::date)
}),
view_count: about
.view_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
country: about.country.and_then(|c| util::country_from_name(&c)),
links,
},
warnings,
})
}
@ -549,18 +564,7 @@ fn combine_channel_data<T>(channel_data: Channel<()>, content: T) -> Channel<T>
}
/// Get the continuation token to fetch channel videos in the given order
fn order_ctoken(channel_id: &str, tab: ChannelVideoTab, order: ChannelOrder) -> String {
_order_ctoken(
channel_id,
tab,
order,
&format!("\n${}", util::random_uuid()),
)
}
/// Get the continuation token to fetch channel videos in the given order
/// (fixed targetId for testing)
fn _order_ctoken(
fn order_ctoken(
channel_id: &str,
tab: ChannelVideoTab,
order: ChannelOrder,
@ -589,6 +593,32 @@ fn _order_ctoken(
pb.to_base64()
}
/// Get the continuation token to fetch channel
fn channel_info_ctoken(channel_id: &str, target_id: &str) -> String {
let mut pb_3 = ProtoBuilder::new();
pb_3.string(19, target_id);
let mut pb_110 = ProtoBuilder::new();
pb_110.embedded(3, pb_3);
let mut pbi = ProtoBuilder::new();
pbi.embedded(110, pb_110);
let mut pb_80226972 = ProtoBuilder::new();
pb_80226972.string(2, channel_id);
pb_80226972.string(3, &pbi.to_base64());
let mut pb = ProtoBuilder::new();
pb.embedded(80_226_972, pb_80226972);
pb.to_base64()
}
/// Create a random UUId to build continuation tokens
fn random_target() -> String {
format!("\n${}", util::random_uuid())
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
@ -604,7 +634,7 @@ mod tests {
util::tests::TESTFILES,
};
use super::_order_ctoken;
use super::{channel_info_ctoken, order_ctoken};
#[rstest]
#[case::base("videos_base", "UC2DjFE7Xf11URZqWBigcVOQ")]
@ -668,10 +698,10 @@ mod tests {
let json_path = path!(*TESTFILES / "channel" / "channel_info.json");
let json_file = File::open(json_path).unwrap();
let channel: response::Channel =
let channel: response::ChannelAbout =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Channel<ChannelInfo>> = channel
.map_response("UC2DjFE7Xf11URZqWBigcVOQ", Language::En, None, None)
let map_res: MapResult<ChannelInfo> = channel
.map_response("UC2DjFE7Xf11U-RZqWBigcVOQ", Language::En, None, None)
.unwrap();
assert!(
@ -683,10 +713,10 @@ mod tests {
}
#[test]
fn order_ctoken() {
fn t_order_ctoken() {
let channel_id = "UCXuqSBlHAE6Xw-yeJA0Tunw";
let videos_popular_token = _order_ctoken(
let videos_popular_token = order_ctoken(
channel_id,
ChannelVideoTab::Videos,
ChannelOrder::Popular,
@ -694,7 +724,7 @@ mod tests {
);
assert_eq!(videos_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXg2S2hJbUNpUTJORFl4WkRkak9DMHdNREF3TFRJd05EQXRPRGRoWVMwd09EbGxNRGd5TjJVME1qQVlBZyUzRCUzRA%3D%3D");
let shorts_popular_token = _order_ctoken(
let shorts_popular_token = order_ctoken(
channel_id,
ChannelVideoTab::Shorts,
ChannelOrder::Popular,
@ -702,7 +732,7 @@ mod tests {
);
assert_eq!(shorts_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXhTS2hJbUNpUTJORFkzT1dabVlpMHdNREF3TFRJMllqTXRZVEZpWkMwMU9ESTBNamxrTW1NM09UUVlBZyUzRCUzRA%3D%3D");
let live_popular_token = _order_ctoken(
let live_popular_token = order_ctoken(
channel_id,
ChannelVideoTab::Live,
ChannelOrder::Popular,
@ -710,4 +740,12 @@ mod tests {
);
assert_eq!(live_popular_token, "4qmFsgJkEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaSDhnWXVHaXh5S2hJbUNpUTJORFk1TXpBMk9TMHdNREF3TFRKaE1XVXRPR00zWkMwMU9ESTBNamxpWkRWaVlUZ1lBZyUzRCUzRA%3D%3D");
}
#[test]
fn t_channel_info_ctoken() {
let channel_id = "UCh8gHdtzO2tXd593_bjErWg";
let token = channel_info_ctoken(channel_id, "\n$655b339a-0000-20b9-92dc-582429d254b4");
assert_eq!(token, "4qmFsgJgEhhVQ2g4Z0hkdHpPMnRYZDU5M19iakVyV2caRDhnWXJHaW1hQVNZS0pEWTFOV0l6TXpsaExUQXdNREF0TWpCaU9TMDVNbVJqTFRVNE1qUXlPV1F5TlRSaU5BJTNEJTNE");
}
}

View file

@ -2,10 +2,10 @@ use serde::Deserialize;
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
use super::{
video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ResponseContext,
Thumbnails, TwoColumnBrowseResults,
video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentsRenderer, ContinuationActionWrap,
ResponseContext, Thumbnails, TwoColumnBrowseResults,
};
use crate::serializer::text::Text;
use crate::serializer::text::{AttributedText, Text, TextComponent};
#[serde_as]
#[derive(Debug, Deserialize)]
@ -145,3 +145,66 @@ pub(crate) struct MicroformatDataRenderer {
#[serde(default)]
pub tags: Vec<String>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelAbout {
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AboutChannelRendererWrap {
pub about_channel_renderer: AboutChannelRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AboutChannelRenderer {
pub metadata: ChannelMetadata,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelMetadata {
pub about_channel_view_model: ChannelMetadataView,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelMetadataView {
pub channel_id: String,
pub canonical_channel_url: String,
pub country: Option<String>,
#[serde(default)]
pub description: String,
#[serde_as(as = "Option<Text>")]
pub joined_date_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub subscriber_count_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub video_count_text: Option<String>,
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
#[serde(default)]
pub links: Vec<ExternalLink>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLink {
pub channel_external_link_view_model: ExternalLinkInner,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLinkInner {
#[serde_as(as = "AttributedText")]
pub title: TextComponent,
#[serde_as(as = "AttributedText")]
pub link: TextComponent,
}

View file

@ -16,6 +16,7 @@ pub(crate) mod video_details;
pub(crate) mod video_item;
pub(crate) use channel::Channel;
pub(crate) use channel::ChannelAbout;
pub(crate) use music_artist::MusicArtist;
pub(crate) use music_artist::MusicArtistAlbums;
pub(crate) use music_charts::MusicCharts;
@ -208,7 +209,7 @@ pub(crate) struct Continuation {
alias = "onResponseReceivedEndpoints"
)]
#[serde_as(as = "Option<VecSkipError<_>>")]
pub on_response_received_actions: Option<Vec<ContinuationActionWrap>>,
pub on_response_received_actions: Option<Vec<ContinuationActionWrap<YouTubeListItem>>>,
/// Used for channel video rich grid renderer
///
/// A/B test seen on 19.10.2022
@ -217,15 +218,15 @@ pub(crate) struct Continuation {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationActionWrap {
pub(crate) struct ContinuationActionWrap<T> {
#[serde(alias = "reloadContinuationItemsCommand")]
pub append_continuation_items_action: ContinuationAction,
pub append_continuation_items_action: ContinuationAction<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationAction {
pub continuation_items: MapResult<Vec<YouTubeListItem>>,
pub(crate) struct ContinuationAction<T> {
pub continuation_items: MapResult<Vec<T>>,
}
#[derive(Debug, Deserialize)]

View file

@ -1,7 +1,7 @@
use serde::Deserialize;
use serde_with::{serde_as, DefaultOnError};
use crate::{model::UrlTarget, util};
use crate::model::UrlTarget;
/// navigation/resolve_url response model
#[derive(Debug, Deserialize)]
@ -280,14 +280,4 @@ impl NavigationEndpoint {
None
}
}
/// Get the sanitized URL from a url endpoint
pub(crate) fn url(&self) -> Option<String> {
match self {
NavigationEndpoint::Url { url_endpoint } => {
Some(util::sanitize_yt_url(&url_endpoint.url))
}
_ => None,
}
}
}

View file

@ -6,15 +6,15 @@ use serde_with::{
};
use time::OffsetDateTime;
use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails};
use super::{ChannelBadge, ContinuationEndpoint, Thumbnails};
use crate::{
model::{
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, Verification,
VideoItem, YouTubeItem,
Channel, ChannelId, ChannelItem, ChannelTag, PlaylistItem, Verification, VideoItem,
YouTubeItem,
},
param::Language,
serializer::{
text::{AccessibilityText, AttributedText, Text, TextComponent},
text::{AccessibilityText, Text, TextComponent},
MapResult,
},
util::{self, timeago, TryRemove},
@ -48,9 +48,6 @@ pub(crate) enum YouTubeListItem {
corrected_query: String,
},
/// Channel metadata (about tab)
ChannelAboutFullMetadataRenderer(ChannelFullMetadata),
/// Contains video on startpage
///
/// Seems to be currently A/B tested on the channel page,
@ -358,47 +355,6 @@ pub(crate) struct ReelPlayerHeaderRenderer {
pub timestamp_text: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelFullMetadata {
#[serde_as(as = "Text")]
pub joined_date_text: String,
#[serde_as(as = "Option<Text>")]
pub view_count_text: Option<String>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub primary_links: Vec<PrimaryLink>,
#[serde(default)]
// #[serde_as(as = "VecSkipError<_>")]
pub links: Vec<ExternalLink>,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PrimaryLink {
#[serde_as(as = "Text")]
pub title: String,
pub navigation_endpoint: NavigationEndpoint,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLink {
pub channel_external_link_view_model: ExternalLinkInner,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalLinkInner {
#[serde_as(as = "AttributedText")]
pub title: TextComponent,
#[serde_as(as = "AttributedText")]
pub link: TextComponent,
}
trait IsLive {
fn is_live(&self) -> bool;
}
@ -446,7 +402,6 @@ pub(crate) struct YouTubeListMapper<T> {
pub warnings: Vec<String>,
pub ctoken: Option<String>,
pub corrected_query: Option<String>,
pub channel_info: Option<ChannelInfo>,
}
impl<T> YouTubeListMapper<T> {
@ -458,7 +413,6 @@ impl<T> YouTubeListMapper<T> {
warnings: Vec::new(),
ctoken: None,
corrected_query: None,
channel_info: None,
}
}
@ -476,7 +430,6 @@ impl<T> YouTubeListMapper<T> {
warnings,
ctoken: None,
corrected_query: None,
channel_info: None,
}
}
@ -744,32 +697,6 @@ impl YouTubeListMapper<YouTubeItem> {
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
self.corrected_query = Some(corrected_query);
}
YouTubeListItem::ChannelAboutFullMetadataRenderer(meta) => {
let mut links = meta
.primary_links
.into_iter()
.filter_map(|l| l.navigation_endpoint.url().map(|url| (l.title, url)))
.collect::<Vec<_>>();
for l in meta.links {
let l = l.channel_external_link_view_model;
if let TextComponent::Web { url, .. } = l.link {
links.push((l.title.into(), util::sanitize_yt_url(&url)));
}
}
self.channel_info = Some(ChannelInfo {
create_date: timeago::parse_textual_date_or_warn(
self.lang,
&meta.joined_date_text,
&mut self.warnings,
)
.map(OffsetDateTime::date),
view_count: meta
.view_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut self.warnings)),
links,
});
}
YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content);
}

View file

@ -2,152 +2,15 @@
source: src/client/channel.rs
expression: map_res.c
---
Channel(
ChannelInfo(
id: "UC2DjFE7Xf11URZqWBigcVOQ",
name: "EEVblog",
subscriber_count: Some(881000),
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s48-c-k-c0x00ffffff-no-rj",
width: 48,
height: 48,
),
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AMLnZu9eKk4Nd16fX4Rn1TF1G7ReluwOl6M5558FTYAM=s176-c-k-c0x00ffffff-no-rj",
width: 176,
height: 176,
),
],
verification: Verified,
url: "http://www.youtube.com/@EEVblog",
description: "NO SCRIPT, NO FEAR, ALL OPINION\nAn off-the-cuff Video Blog about Electronics Engineering, for engineers, hobbyists, enthusiasts, hackers and Makers\nHosted by Dave Jones from Sydney Australia\n\nDONATIONS:\nBitcoin: 3KqyH1U3qrMPnkLufM2oHDU7YB4zVZeFyZ\nEthereum: 0x99ccc4d2654ba40744a1f678d9868ecb15e91206\nPayPal: david@alternatezone.com\n\nPatreon: https://www.patreon.com/eevblog\n\nEEVblog2: http://www.youtube.com/EEVblog2\nEEVdiscover: https://www.youtube.com/channel/UCkGvUEt8iQLmq3aJIMjT2qQ\n\nEMAIL:\nAdvertising/Commercial: eevblog+business@gmail.com\nFan mail: eevblog+fan@gmail.com\nHate Mail: eevblog+hate@gmail.com\n\nI DON\'T DO PAID VIDEO SPONSORSHIPS, DON\'T ASK!\n\nPLEASE:\nDo NOT ask for personal advice on something, post it in the EEVblog forum.\nI read ALL email, but please don\'t be offended if I don\'t have time to reply, I get a LOT of email.\n\nMailbag\nPO Box 7949\nBaulkham Hills NSW 2153\nAUSTRALIA",
tags: [
"electronics",
"engineering",
"maker",
"hacker",
"design",
"circuit",
"hardware",
"pic",
"atmel",
"oscilloscope",
"multimeter",
"diy",
"hobby",
"review",
"teardown",
"microcontroller",
"arduino",
"video",
"blog",
"tutorial",
"how-to",
"interview",
"rant",
"industry",
"news",
"mailbag",
"dumpster diving",
"debunking",
],
vanity_url: Some("https://www.youtube.com/c/EevblogDave"),
banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1060-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1060,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1138-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1138,
height: 188,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 1707,
height: 283,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2276-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2276,
height: 377,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj",
width: 2560,
height: 424,
),
],
mobile_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 320,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w640-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 640,
height: 175,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w960-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 960,
height: 263,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 351,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1440-fcrop64=1,32b75a57cd48a5a8-k-c0xffffffff-no-nd-rj",
width: 1440,
height: 395,
),
],
tv_banner: [
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w320-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 320,
height: 180,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w854-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 854,
height: 480,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1280-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1280,
height: 720,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w1920-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 1920,
height: 1080,
),
Thumbnail(
url: "https://yt3.ggpht.com/yIJ9ad80n49rK-YUcZLe_8bLmR-aGyg5ybDH_XKIc0GDWrC6s1Wzz8lxnq3_hux_5b6NHPZ9=w2120-fcrop64=1,00000000ffffffff-k-c0xffffffff-no-nd-rj",
width: 2120,
height: 1192,
),
],
has_shorts: false,
has_live: false,
visitor_data: Some("CgszMUUzZDlGLWxiRSipqr2ZBg%3D%3D"),
content: ChannelInfo(
subscriber_count: Some(920000),
video_count: Some(1920),
create_date: Some("2009-04-04"),
view_count: Some(186854342),
view_count: Some(199087682),
country: Some(AU),
links: [
("EEVblog Web Site", "http://www.eevblog.com/"),
("Twitter", "http://www.twitter.com/eevblog"),
@ -163,5 +26,4 @@ Channel(
("EEVblog AMAZON Store", "http://www.amazon.com/gp/redirect.html?ie=UTF8&location=http%3A%2F%2Fwww.amazon.com%2F&tag=ee04-20&linkCode=ur2&camp=1789&creative=390957"),
("2nd EEVblog Channel", "http://www.youtube.com/EEVblog2"),
],
),
)

View file

@ -738,16 +738,31 @@ pub struct Channel<T> {
pub content: T,
}
/// Additional channel metadata fetched from the "About" tab.
/// Detailed channel information
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct ChannelInfo {
/// Unique YouTube Channel-ID (e.g. `UC-lHJZR3Gqxm24_Vd_AJ5Yw`)
pub id: String,
/// Channel URL
pub url: String,
/// Channel description text
pub description: String,
/// Channel subscriber count
///
/// [`None`] if the subscriber count was hidden by the owner
/// or could not be parsed.
pub subscriber_count: Option<u64>,
/// Channel video count
pub video_count: Option<u64>,
/// Channel creation date
#[serde_as(as = "Option<DateYmd>")]
pub create_date: Option<Date>,
/// Channel view count
pub view_count: Option<u64>,
/// Channel origin country
pub country: Option<Country>,
/// Links to other websites or social media profiles
pub links: Vec<(String, String)>,
}

View file

@ -419,202 +419,207 @@ pub enum Country {
}
/// Array of all available languages
/// The languages are sorted by their native names. This array can be used to display
/// a language selection or to get the language code from a language name using binary search.
pub const LANGUAGES: [Language; 83] = [
Language::Af,
Language::Am,
Language::Ar,
Language::As,
Language::Az,
Language::Be,
Language::Bg,
Language::Bn,
Language::Id,
Language::Ms,
Language::Bs,
Language::Ca,
Language::Cs,
Language::Da,
Language::De,
Language::El,
Language::En,
Language::EnGb,
Language::Et,
Language::EnIn,
Language::EnGb,
Language::En,
Language::Es,
Language::Es419,
Language::EsUs,
Language::Et,
Language::Eu,
Language::Fa,
Language::Fi,
Language::Fil,
Language::Fr,
Language::FrCa,
Language::Gl,
Language::Gu,
Language::Hi,
Language::Hr,
Language::Hu,
Language::Hy,
Language::Id,
Language::Is,
Language::Zu,
Language::It,
Language::Iw,
Language::Ja,
Language::Ka,
Language::Kk,
Language::Km,
Language::Kn,
Language::Ko,
Language::Ky,
Language::Lo,
Language::Lt,
Language::Sw,
Language::Lv,
Language::Mk,
Language::Ml,
Language::Mn,
Language::Mr,
Language::Ms,
Language::My,
Language::Ne,
Language::Lt,
Language::Hu,
Language::Nl,
Language::No,
Language::Or,
Language::Pa,
Language::Uz,
Language::Pl,
Language::Pt,
Language::PtPt,
Language::Pt,
Language::Ro,
Language::Ru,
Language::Si,
Language::Sq,
Language::Sk,
Language::Sl,
Language::Sq,
Language::Sr,
Language::SrLatn,
Language::Fi,
Language::Sv,
Language::Sw,
Language::Vi,
Language::Tr,
Language::Is,
Language::Cs,
Language::El,
Language::Be,
Language::Bg,
Language::Ky,
Language::Mk,
Language::Mn,
Language::Ru,
Language::Sr,
Language::Uk,
Language::Kk,
Language::Hy,
Language::Iw,
Language::Ur,
Language::Ar,
Language::Fa,
Language::Ne,
Language::Mr,
Language::Hi,
Language::As,
Language::Bn,
Language::Pa,
Language::Gu,
Language::Or,
Language::Ta,
Language::Te,
Language::Kn,
Language::Ml,
Language::Si,
Language::Th,
Language::Tr,
Language::Uk,
Language::Ur,
Language::Uz,
Language::Vi,
Language::Lo,
Language::My,
Language::Ka,
Language::Am,
Language::Km,
Language::ZhCn,
Language::ZhHk,
Language::ZhTw,
Language::Zu,
Language::ZhHk,
Language::Ja,
Language::Ko,
];
/// Array of all available countries
///
/// The countries are sorted by their english names. This array can be used to display
/// a country selection or to get the country code from a country name using binary search.
pub const COUNTRIES: [Country; 109] = [
Country::Ae,
Country::Dz,
Country::Ar,
Country::At,
Country::Au,
Country::At,
Country::Az,
Country::Ba,
Country::Bd,
Country::Be,
Country::Bg,
Country::Bh,
Country::Bo,
Country::Br,
Country::Bd,
Country::By,
Country::Be,
Country::Bo,
Country::Ba,
Country::Br,
Country::Bg,
Country::Kh,
Country::Ca,
Country::Ch,
Country::Cl,
Country::Co,
Country::Cr,
Country::Hr,
Country::Cy,
Country::Cz,
Country::De,
Country::Dk,
Country::Do,
Country::Dz,
Country::Ec,
Country::Ee,
Country::Eg,
Country::Es,
Country::Sv,
Country::Ee,
Country::Fi,
Country::Fr,
Country::Gb,
Country::Ge,
Country::De,
Country::Gh,
Country::Gr,
Country::Gt,
Country::Hk,
Country::Hn,
Country::Hr,
Country::Hk,
Country::Hu,
Country::Is,
Country::In,
Country::Id,
Country::Iq,
Country::Ie,
Country::Il,
Country::In,
Country::Iq,
Country::Is,
Country::It,
Country::Jm,
Country::Jo,
Country::Jp,
Country::Ke,
Country::Kh,
Country::Kr,
Country::Kw,
Country::Jo,
Country::Kz,
Country::Ke,
Country::Kw,
Country::La,
Country::Lv,
Country::Lb,
Country::Ly,
Country::Li,
Country::Lk,
Country::Lt,
Country::Lu,
Country::Lv,
Country::Ly,
Country::Ma,
Country::Me,
Country::Mk,
Country::My,
Country::Mt,
Country::Mx,
Country::My,
Country::Ng,
Country::Ni,
Country::Nl,
Country::No,
Country::Me,
Country::Ma,
Country::Np,
Country::Nl,
Country::Nz,
Country::Ni,
Country::Ng,
Country::Mk,
Country::No,
Country::Om,
Country::Pa,
Country::Pe,
Country::Pg,
Country::Ph,
Country::Pk,
Country::Pl,
Country::Pr,
Country::Pt,
Country::Pa,
Country::Pg,
Country::Py,
Country::Pe,
Country::Ph,
Country::Pl,
Country::Pt,
Country::Pr,
Country::Qa,
Country::Ro,
Country::Rs,
Country::Ru,
Country::Sa,
Country::Se,
Country::Sg,
Country::Si,
Country::Sk,
Country::Sn,
Country::Sv,
Country::Rs,
Country::Sg,
Country::Sk,
Country::Si,
Country::Za,
Country::Kr,
Country::Es,
Country::Lk,
Country::Se,
Country::Ch,
Country::Tw,
Country::Tz,
Country::Th,
Country::Tn,
Country::Tr,
Country::Tw,
Country::Tz,
Country::Ua,
Country::Ug,
Country::Ua,
Country::Ae,
Country::Gb,
Country::Us,
Country::Uy,
Country::Ve,
Country::Vn,
Country::Ye,
Country::Za,
Country::Zw,
];
@ -844,11 +849,7 @@ impl FromStr for Language {
Some(pos) => {
sub = &sub[..pos];
}
None => {
return Err(Error::Other(
format!("could not parse language `{s}`").into(),
))
}
None => return Err(Error::Other("could not parse language `{s}`".into())),
}
}
}

View file

@ -44,13 +44,14 @@ use crate::{
#[serde(untagged)]
pub(crate) enum Text {
Simple {
#[serde(alias = "simpleText")]
#[serde(alias = "simpleText", alias = "content")]
text: String,
},
Multiple {
#[serde_as(as = "Vec<Text>")]
runs: Vec<String>,
},
Str(String),
}
impl<'de> DeserializeAs<'de, String> for Text {
@ -60,7 +61,7 @@ impl<'de> DeserializeAs<'de, String> for Text {
{
let text = Text::deserialize(deserializer)?;
match text {
Text::Simple { text } => Ok(text),
Text::Simple { text } | Text::Str(text) => Ok(text),
Text::Multiple { runs } => Ok(runs.join("")),
}
}
@ -73,7 +74,7 @@ impl<'de> DeserializeAs<'de, Vec<String>> for Text {
{
let text = Text::deserialize(deserializer)?;
match text {
Text::Simple { text } => Ok(vec![text]),
Text::Simple { text } | Text::Str(text) => Ok(vec![text]),
Text::Multiple { runs } => Ok(runs),
}
}
@ -542,6 +543,7 @@ mod tests {
}"#,
vec!["Abo für ", "MBCkpop", " beenden?"]
)]
#[case(r#"{"txt":"Hello World"}"#, vec!["Hello World"])]
fn t_deserialize_text(#[case] test_json: &str, #[case] exp: Vec<&str>) {
#[serde_as]
#[derive(Deserialize)]

View file

@ -19,7 +19,11 @@ use rand::Rng;
use regex::Regex;
use url::Url;
use crate::{error::Error, param::Language, serializer::text::TextComponent};
use crate::{
error::Error,
param::{Country, Language, COUNTRIES},
serializer::text::TextComponent,
};
pub static VIDEO_ID_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{11}$").unwrap());
pub static CHANNEL_ID_REGEX: Lazy<Regex> =
@ -462,6 +466,14 @@ pub fn b64_decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, base64::DecodeErr
base64::engine::general_purpose::STANDARD.decode(input)
}
/// Get the country from its English name
pub fn country_from_name(name: &str) -> Option<Country> {
COUNTRIES
.binary_search_by_key(&name, Country::name)
.ok()
.map(|i| COUNTRIES[i])
}
/// An iterator over the chars in a string (in str format)
pub struct SplitChar<'a> {
txt: &'a str,
@ -685,4 +697,13 @@ pub(crate) mod tests {
let res = Language::from_str(s).ok();
assert_eq!(res, expect);
}
#[rstest]
#[case("United States", Some(Country::Us))]
#[case("Zimbabwe", Some(Country::Zw))]
#[case("foobar", None)]
fn t_country_from_name(#[case] name: &str, #[case] expect: Option<Country>) {
let res = country_from_name(name);
assert_eq!(res, expect);
}
}

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,10 @@ fn get_player_from_client(#[case] client_type: ClientType, rp: RustyPipe) {
// dbg!(&player_data);
assert_eq!(player_data.details.id, "n4tK7LYFxI0");
assert_eq!(player_data.details.name, "Spektrem - Shine [NCS Release]");
assert_eq!(
player_data.details.name,
"Spektrem - Shine | Progressive House | NCS - Copyright Free Music"
);
if client_type == ClientType::DesktopMusic {
assert!(player_data.details.description.is_none());
} else {
@ -68,9 +71,9 @@ fn get_player_from_client(#[case] client_type: ClientType, rp: RustyPipe) {
.unwrap();
// Bitrates may change between requests
assert_approx(f64::from(video.bitrate), 1_507_068.0);
assert_eq!(video.average_bitrate, 1_345_149);
assert_eq!(video.size.unwrap(), 43_553_412);
assert_approx(f64::from(video.bitrate), 1_851_854.0);
assert_eq!(video.average_bitrate, 923_766);
assert_eq!(video.size.unwrap(), 29_909_835);
assert_eq!(video.width, 1280);
assert_eq!(video.height, 720);
assert_eq!(video.fps, 30);
@ -102,8 +105,8 @@ fn get_player_from_client(#[case] client_type: ClientType, rp: RustyPipe) {
.expect("audio stream not found");
assert_approx(f64::from(video.bitrate), 1_340_829.0);
assert_approx(f64::from(video.average_bitrate), 1_233_444.0);
assert_approx(video.size.unwrap() as f64, 39_936_630.0);
assert_approx(f64::from(video.average_bitrate), 1_046_557.0);
assert_approx(video.size.unwrap() as f64, 33_885_572.0);
assert_eq!(video.width, 1280);
assert_eq!(video.height, 720);
assert_eq!(video.fps, 30);
@ -856,22 +859,15 @@ fn channel_playlists(rp: RustyPipe) {
#[rstest]
fn channel_info(rp: RustyPipe) {
let channel =
tokio_test::block_on(rp.query().channel_info("UC2DjFE7Xf11URZqWBigcVOQ")).unwrap();
let info = tokio_test::block_on(rp.query().channel_info("UC2DjFE7Xf11URZqWBigcVOQ")).unwrap();
// dbg!(&channel);
assert_channel_eevblog(&channel);
assert_eq!(info.create_date.unwrap(), date!(2009 - 4 - 4));
assert_gte(info.view_count.unwrap(), 186_854_340, "channel views");
assert_gte(info.video_count.unwrap(), 1920, "channel videos");
assert_gte(info.subscriber_count.unwrap(), 920_000, "subscribers");
assert_eq!(info.country.unwrap(), Country::Au);
let created = channel.content.create_date.unwrap();
assert_eq!(created, date!(2009 - 4 - 4));
assert_gte(
channel.content.view_count.unwrap(),
186_854_340,
"channel views",
);
insta::assert_ron_snapshot!(channel.content.links, @r###"
insta::assert_ron_snapshot!(info.links, @r###"
[
("EEVblog Web Site", "http://www.eevblog.com/"),
("Twitter", "http://www.twitter.com/eevblog"),
@ -967,8 +963,8 @@ fn channel_more(
);
}
let channel_info = tokio_test::block_on(rp.query().channel_info(&id)).unwrap();
assert_channel(&channel_info, id, name, unlocalized || name_unlocalized);
let info = tokio_test::block_on(rp.query().channel_info(&id)).unwrap();
assert_eq!(info.id, id);
}
#[rstest]
@ -2447,8 +2443,8 @@ fn assert_frameset(frameset: &Frameset) {
assert_gte(frameset.frame_height, 20, "frame width");
assert_gte(frameset.page_count, 1, "page count");
assert_gte(frameset.total_count, 50, "total count");
assert_gte(frameset.frames_per_page_x, 5, "frames per page x");
assert_gte(frameset.frames_per_page_y, 5, "frames per page y");
assert_gte(frameset.frames_per_page_x, 3, "frames per page x");
assert_gte(frameset.frames_per_page_y, 3, "frames per page y");
let n = frameset.urls().count() as u32;
assert_eq!(n, frameset.page_count);