use std::collections::BTreeMap; use anyhow::{bail, Result}; use futures_util::{stream, StreamExt}; use indicatif::{ProgressBar, ProgressStyle}; use num_enum::TryFromPrimitive; use once_cell::sync::Lazy; use regex::Regex; use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery}; use rustypipe::model::{MusicItem, YouTubeItem}; use rustypipe::param::search_filter::{ItemType, SearchFilter}; use rustypipe::param::ChannelVideoTab; use serde::de::IgnoredAny; use serde::{Deserialize, Serialize}; use crate::model::QCont; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, TryFromPrimitive, Serialize, Deserialize, )] #[repr(u16)] pub enum ABTest { AttributedTextDescription = 1, ThreeTabChannelLayout = 2, ChannelHandlesInSearchResults = 3, TrendsVideoTab = 4, TrendsPageHeaderRenderer = 5, DiscographyPage = 6, ShortDateFormat = 7, TrackViewcount = 8, PlaylistsForShorts = 9, ChannelAboutModal = 10, LikeButtonViewmodel = 11, ChannelPageHeader = 12, MusicPlaylistTwoColumn = 13, CommentsFrameworkUpdate = 14, ChannelShortsLockup = 15, PlaylistPageHeader = 16, ChannelPlaylistsLockup = 17, MusicPlaylistFacepile = 18, MusicAlbumGroupsReordered = 19, MusicContinuationItemRenderer = 20, } /// List of active A/B tests that are run when none is manually specified const TESTS_TO_RUN: &[ABTest] = &[ ABTest::MusicAlbumGroupsReordered, ABTest::MusicContinuationItemRenderer, ]; #[derive(Debug, Serialize, Deserialize)] pub struct ABTestRes { id: u16, name: ABTest, tests: usize, occurrences: usize, vd_present: Option, vd_absent: Option, } #[derive(Debug, Serialize)] struct QVideo<'a> { video_id: &'a str, content_check_ok: bool, racy_check_ok: bool, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct QBrowse<'a> { browse_id: &'a str, #[serde(skip_serializing_if = "Option::is_none")] params: Option<&'a str>, } pub async fn run_test( ab: ABTest, n: usize, concurrency: usize, ) -> (usize, Option, Option) { eprintln!("🧪 A/B test #{}: {:?}", ab as u16, ab); let rp = RustyPipe::new(); let pb = ProgressBar::new(n as u64); pb.set_style( ProgressStyle::with_template( "{msg} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}", ) .unwrap(), ); let results = stream::iter(0..n) .map(|_| { let rp = rp.clone(); let pb = pb.clone(); async move { let visitor_data = rp.query().get_visitor_data().await.unwrap(); let query = rp.query().visitor_data(&visitor_data); let is_present = match ab { ABTest::AttributedTextDescription => attributed_text_description(&query).await, ABTest::ThreeTabChannelLayout => three_tab_channel_layout(&query).await, ABTest::ChannelHandlesInSearchResults => { channel_handles_in_search_results(&query).await } ABTest::TrendsVideoTab => trends_video_tab(&query).await, ABTest::TrendsPageHeaderRenderer => trends_page_header_renderer(&query).await, ABTest::DiscographyPage => discography_page(&query).await, 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, ABTest::LikeButtonViewmodel => like_button_viewmodel(&query).await, ABTest::ChannelPageHeader => channel_page_header(&query).await, ABTest::MusicPlaylistTwoColumn => music_playlist_two_column(&query).await, ABTest::CommentsFrameworkUpdate => comments_framework_update(&query).await, ABTest::ChannelShortsLockup => channel_shorts_lockup(&query).await, ABTest::PlaylistPageHeader => playlist_page_header_renderer(&query).await, ABTest::ChannelPlaylistsLockup => channel_playlists_lockup(&query).await, ABTest::MusicPlaylistFacepile => music_playlist_facepile(&query).await, ABTest::MusicAlbumGroupsReordered => music_album_groups_reordered(&query).await, ABTest::MusicContinuationItemRenderer => { music_continuation_item_renderer(&query).await } } .unwrap(); pb.inc(1); (is_present, visitor_data) } }) .buffer_unordered(concurrency) .collect::>() .await; let count = results.iter().filter(|(p, _)| *p).count(); let vd_present = results .iter() .find_map(|(p, vd)| if *p { Some(vd.clone()) } else { None }); let vd_absent = results .iter() .find_map(|(p, vd)| if *p { None } else { Some(vd.clone()) }); (count, vd_present, vd_absent) } pub async fn run_all_tests(n: usize, concurrency: usize) -> Vec { let mut results = Vec::new(); for ab in TESTS_TO_RUN { let (occurrences, vd_present, vd_absent) = run_test(*ab, n, concurrency).await; results.push(ABTestRes { id: *ab as u16, name: *ab, tests: n, occurrences, vd_present, vd_absent, }); } results } pub async fn attributed_text_description(rp: &RustyPipeQuery) -> Result { let q = QVideo { video_id: "ZeerrnuLi5E", content_check_ok: false, racy_check_ok: false, }; let response_txt = rp.raw(ClientType::Desktop, "next", &q).await?; if !response_txt.contains("\"Black Mamba\"") { bail!("invalid response data"); } Ok(response_txt.contains("\"attributedDescription\"")) } pub async fn three_tab_channel_layout(rp: &RustyPipeQuery) -> Result { let channel = rp.channel_videos("UCR-DXc1voovS8nhAvccRZhg").await?; Ok(channel.has_live || channel.has_shorts) } pub async fn channel_handles_in_search_results(rp: &RustyPipeQuery) -> Result { let search = rp .search_filter("rust", &SearchFilter::new().item_type(ItemType::Channel)) .await .unwrap(); Ok(search.items.items.iter().any(|itm| match itm { YouTubeItem::Channel(channel) => channel .subscriber_count .map(|sc| sc > 100 && channel.handle.is_some()) .unwrap_or_default(), _ => false, })) } pub async fn trends_video_tab(rp: &RustyPipeQuery) -> Result { let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: "FEtrending", params: None, }, ) .await?; Ok(res.contains("\"4gIOGgxtb3N0X3BvcHVsYXI%3D\"")) } pub async fn trends_page_header_renderer(rp: &RustyPipeQuery) -> Result { let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: "FEtrending", params: None, }, ) .await?; #[derive(Debug, Deserialize)] struct D { header: BTreeMap, } let data = serde_json::from_str::(&res)?; Ok(data.header.contains_key("pageHeaderRenderer")) } pub async fn discography_page(rp: &RustyPipeQuery) -> Result { let id = "UC7cl4MmM6ZZ2TcFyMk_b4pg"; let res = rp .raw( ClientType::DesktopMusic, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains(&format!("\"MPAD{id}\""))) } pub async fn short_date_format(rp: &RustyPipeQuery) -> Result { static SHORT_DATE: Lazy = Lazy::new(|| Regex::new("\\d(?:y|mo|w|d|h|min) ").unwrap()); let channel = rp.channel_videos("UC2DjFE7Xf11URZqWBigcVOQ").await?; Ok(channel.content.items.iter().any(|itm| { itm.publish_date_txt .as_deref() .map(|d| SHORT_DATE.is_match(d)) .unwrap_or_default() })) } pub async fn playlists_for_shorts(rp: &RustyPipeQuery) -> Result { 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 { let res = rp.music_search_main("lieblingsmensch namika").await?; let track = &res .items .items .iter() .find_map(|itm| { if let MusicItem::Track(track) = itm { if track.id == "6485PhOtHzY" { Some(track) } else { None } } else { None } }) .unwrap_or_else(|| { panic!("could not find track, got {:#?}", &res.items.items); }); Ok(track.view_count.is_some()) } pub async fn channel_about_modal(rp: &RustyPipeQuery) -> Result { let id = "UC2DjFE7Xf11URZqWBigcVOQ"; let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(!res.contains("\"EgVhYm91dPIGBAoCEgA%3D\"")) } pub async fn like_button_viewmodel(rp: &RustyPipeQuery) -> Result { let res = rp .raw( ClientType::Desktop, "next", &QVideo { video_id: "ZeerrnuLi5E", content_check_ok: true, racy_check_ok: true, }, ) .await?; Ok(res.contains("\"segmentedLikeDislikeButtonViewModel\"")) } pub async fn channel_page_header(rp: &RustyPipeQuery) -> Result { let channel = rp .channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts) .await?; Ok(channel.video_count.is_some()) } pub async fn music_playlist_two_column(rp: &RustyPipeQuery) -> Result { let id = "VLRDCLAK5uy_kb7EBi6y3GrtJri4_ZH56Ms786DFEimbM"; let res = rp .raw( ClientType::DesktopMusic, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains("\"musicResponsiveHeaderRenderer\"")) } pub async fn comments_framework_update(rp: &RustyPipeQuery) -> Result { let continuation = "Eg0SC3dMZHBSN2d1S3k4GAYyJSIRIgt3TGRwUjdndUt5ODAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D"; let res = rp .raw(ClientType::Desktop, "next", &QCont { continuation }) .await?; Ok(res.contains("\"frameworkUpdates\"")) } pub async fn channel_shorts_lockup(rp: &RustyPipeQuery) -> Result { let id = "UCh8gHdtzO2tXd593_bjErWg"; let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: id, params: Some("EgZzaG9ydHPyBgUKA5oBAA%3D%3D"), }, ) .await?; Ok(res.contains("\"shortsLockupViewModel\"")) } pub async fn playlist_page_header_renderer(rp: &RustyPipeQuery) -> Result { let id = "VLPLZN_exA7d4RVmCQrG5VlWIjMOkMFZVVOc"; let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains("\"pageHeaderRenderer\"")) } pub async fn channel_playlists_lockup(rp: &RustyPipeQuery) -> Result { let id = "UC2DjFE7Xf11URZqWBigcVOQ"; let res = rp .raw( ClientType::Desktop, "browse", &QBrowse { browse_id: id, params: Some("EglwbGF5bGlzdHMgAQ%3D%3D"), }, ) .await?; Ok(res.contains("\"lockupViewModel\"")) } pub async fn music_playlist_facepile(rp: &RustyPipeQuery) -> Result { let id = "VLPL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"; let res = rp .raw( ClientType::DesktopMusic, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains("\"facepile\"")) } pub async fn music_album_groups_reordered(rp: &RustyPipeQuery) -> Result { let id = "UCOR4_bSVIXPsGa4BbCSt60Q"; let res = rp .raw( ClientType::DesktopMusic, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains("\"Singles & EPs\"")) } pub async fn music_continuation_item_renderer(rp: &RustyPipeQuery) -> Result { let id = "VLPLbZIPy20-1pN7mqjckepWF78ndb6ci_qi"; let res = rp .raw( ClientType::DesktopMusic, "browse", &QBrowse { browse_id: id, params: None, }, ) .await?; Ok(res.contains("\"continuationItemRenderer\"")) }