fix: A/B test 22: commandExecutorCommand for playlist continuations

This commit is contained in:
ThetaDev 2025-03-16 19:45:14 +01:00
parent fcf27aa3b2
commit e8acbfbbcf
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
12 changed files with 33753 additions and 36 deletions

View file

@ -41,10 +41,15 @@ pub enum ABTest {
MusicAlbumGroupsReordered = 19, MusicAlbumGroupsReordered = 19,
MusicContinuationItemRenderer = 20, MusicContinuationItemRenderer = 20,
AlbumRecommends = 21, AlbumRecommends = 21,
CommandExecutorCommand = 22,
} }
/// List of active A/B tests that are run when none is manually specified /// List of active A/B tests that are run when none is manually specified
const TESTS_TO_RUN: &[ABTest] = &[ABTest::MusicAlbumGroupsReordered, ABTest::AlbumRecommends]; const TESTS_TO_RUN: &[ABTest] = &[
ABTest::MusicAlbumGroupsReordered,
ABTest::AlbumRecommends,
ABTest::CommandExecutorCommand,
];
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ABTestRes { pub struct ABTestRes {
@ -120,6 +125,7 @@ pub async fn run_test(
music_continuation_item_renderer(&query).await music_continuation_item_renderer(&query).await
} }
ABTest::AlbumRecommends => album_recommends(&query).await, ABTest::AlbumRecommends => album_recommends(&query).await,
ABTest::CommandExecutorCommand => command_executor_command(&query).await,
} }
.unwrap(); .unwrap();
pb.inc(1); pb.inc(1);
@ -457,3 +463,18 @@ pub async fn album_recommends(rp: &RustyPipeQuery) -> Result<bool> {
.await?; .await?;
Ok(res.contains("\"musicCarouselShelfRenderer\"")) Ok(res.contains("\"musicCarouselShelfRenderer\""))
} }
pub async fn command_executor_command(rp: &RustyPipeQuery) -> Result<bool> {
let id = "VLPLbZIPy20-1pN7mqjckepWF78ndb6ci_qi";
let res = rp
.raw(
ClientType::Desktop,
"browse",
&QBrowse {
browse_id: id,
params: None,
},
)
.await?;
Ok(res.contains("\"commandExecutorCommand\""))
}

View file

@ -1030,7 +1030,7 @@ commandContext missing).
- **Encountered on:** 13.01.2025 - **Encountered on:** 13.01.2025
- **Impact:** 🟢 Low - **Impact:** 🟢 Low
- **Endpoint:** browse (YTM) - **Endpoint:** browse (YTM)
- **Status:** Common (10%) - **Status:** Frequent (59%)
YouTube Music used to group artist albums into 2 rows: "Albums" and "Singles". YouTube Music used to group artist albums into 2 rows: "Albums" and "Singles".
@ -1067,3 +1067,37 @@ pages. The difficulty is distinguishing them reliably for parsing the album vari
The current solution is adding the "Other versions" title in all languages to the The current solution is adding the "Other versions" title in all languages to the
dictionary and comparing it. dictionary and comparing it.
## [22] commandExecutorCommand for continuations
- **Encountered on:** 16.03.2025
- **Impact:** 🟢 Low
- **Endpoint:** browse (YTM)
- **Status:** Experimental (1%)
YouTube playlists may use a commandExecutorCommand which holds a list of commands: the
`continuationCommand` that needs to be extracted as well as a `playlistVotingRefreshPopupCommand`.
```json
{
"continuationItemRenderer": {
"continuationEndpoint": {
"commandExecutorCommand": {
"commands": [
{
"playlistVotingRefreshPopupCommand": {
"command": {}
}
},
{
"continuationCommand": {
"request": "CONTINUATION_REQUEST_TYPE_BROWSE",
"token": "4qmFsgKBARIkVkxQTGJaSVB5MjAtMXBON21xamNrZXBXRjc4bmRiNmNpX3FpGjRDQUY2SGxCVU9rTklTV2xGUkVreVVtdEZOVTVFU1hsU2FrWkRVa1JKZWs1NldRJTNEJTNEmgIiUExiWklQeTIwLTFwTjdtcWpja2VwV0Y3OG5kYjZjaV9xaQ%3D%3D"
}
}
]
}
}
}
}
```

View file

@ -249,11 +249,9 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
&mut map_res, &mut map_res,
); );
} }
response::YouTubeListItem::ContinuationItemRenderer { response::YouTubeListItem::ContinuationItemRenderer(ep) => {
continuation_endpoint,
} => {
if ctoken.is_none() { if ctoken.is_none() {
ctoken = Some(continuation_endpoint.continuation_command.token); ctoken = ep.continuation_endpoint.into_token();
} }
} }
_ => {} _ => {}

View file

@ -257,6 +257,7 @@ mod tests {
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")] #[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
#[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")] #[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")]
#[case::pageheader("20241011_pageheader", "PLT2w2oBf1TZKyvY_M6JsASs73m-wjLzH5")] #[case::pageheader("20241011_pageheader", "PLT2w2oBf1TZKyvY_M6JsASs73m-wjLzH5")]
#[case::cmdexecutor("20250316_cmdexecutor", "PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")]
fn map_playlist_data(#[case] name: &str, #[case] id: &str) { fn map_playlist_data(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json")); let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();

View file

@ -152,9 +152,16 @@ pub(crate) struct ContinuationItemRenderer {
pub continuation_endpoint: ContinuationEndpoint, pub continuation_endpoint: ContinuationEndpoint,
} }
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ContinuationEndpoint {
ContinuationCommand(ContinuationCommandWrap),
CommandExecutorCommand(CommandExecutorCommandWrap),
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationEndpoint { pub(crate) struct ContinuationCommandWrap {
pub continuation_command: ContinuationCommand, pub continuation_command: ContinuationCommand,
} }
@ -164,7 +171,34 @@ pub(crate) struct ContinuationCommand {
pub token: String, pub token: String,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommandExecutorCommandWrap {
pub command_executor_command: CommandExecutorCommand,
}
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommandExecutorCommand {
#[serde_as(as = "VecSkipError<_>")]
commands: Vec<ContinuationCommandWrap>,
}
impl ContinuationEndpoint {
pub fn into_token(self) -> Option<String> {
match self {
Self::ContinuationCommand(cmd) => Some(cmd.continuation_command.token),
Self::CommandExecutorCommand(cmd) => cmd
.command_executor_command
.commands
.into_iter()
.next()
.map(|c| c.continuation_command.token),
}
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Icon { pub(crate) struct Icon {

View file

@ -530,7 +530,9 @@ impl MusicListMapper {
MusicResponseItem::ContinuationItemRenderer { MusicResponseItem::ContinuationItemRenderer {
continuation_endpoint, continuation_endpoint,
} => { } => {
self.ctoken = Some(continuation_endpoint.continuation_command.token); if self.ctoken.is_none() {
self.ctoken = continuation_endpoint.into_token();
}
Ok(None) Ok(None)
} }
} }

View file

@ -530,15 +530,14 @@ pub(crate) enum ContinuationItemVariants {
} }
impl ContinuationItemVariants { impl ContinuationItemVariants {
pub fn token(self) -> String { pub fn into_token(self) -> Option<String> {
match self { match self {
ContinuationItemVariants::Ep { ContinuationItemVariants::Ep {
continuation_endpoint, continuation_endpoint,
} => continuation_endpoint, } => continuation_endpoint,
ContinuationItemVariants::Btn { button } => button.button_renderer.command, ContinuationItemVariants::Btn { button } => button.button_renderer.command,
} }
.continuation_command .into_token()
.token
} }
} }

View file

@ -4,7 +4,7 @@ use serde_with::{
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails}; use super::{ChannelBadge, ContentImage, ContinuationItemRenderer, PhMetadataView, Thumbnails};
use crate::{ use crate::{
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem}, model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
param::Language, param::Language,
@ -37,12 +37,9 @@ pub(crate) enum YouTubeListItem {
LockupViewModel(LockupViewModel), LockupViewModel(LockupViewModel),
/// Continauation items are located at the end of a list /// Continuation items are located at the end of a list
/// and contain the continuation token for progressive loading /// and contain the continuation token for progressive loading
#[serde(rename_all = "camelCase")] ContinuationItemRenderer(ContinuationItemRenderer),
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
/// Corrected search query /// Corrected search query
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -838,9 +835,11 @@ impl YouTubeListMapper<YouTubeItem> {
self.items.push(mapped); self.items.push(mapped);
} }
} }
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer(r) => {
continuation_endpoint, if self.ctoken.is_none() {
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), self.ctoken = r.continuation_endpoint.into_token();
}
}
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
self.corrected_query = Some(corrected_query); self.corrected_query = Some(corrected_query);
} }
@ -886,9 +885,11 @@ impl YouTubeListMapper<VideoItem> {
self.items.push(mapped); self.items.push(mapped);
} }
} }
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer(r) => {
continuation_endpoint, if self.ctoken.is_none() {
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), self.ctoken = r.continuation_endpoint.into_token();
}
}
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
self.corrected_query = Some(corrected_query); self.corrected_query = Some(corrected_query);
} }
@ -938,9 +939,11 @@ impl YouTubeListMapper<PlaylistItem> {
self.items.push(mapped); self.items.push(mapped);
} }
} }
YouTubeListItem::ContinuationItemRenderer { YouTubeListItem::ContinuationItemRenderer(r) => {
continuation_endpoint, if self.ctoken.is_none() {
} => self.ctoken = Some(continuation_endpoint.continuation_command.token), self.ctoken = r.continuation_endpoint.into_token();
}
}
YouTubeListItem::ShowingResultsForRenderer { corrected_query } => { YouTubeListItem::ShowingResultsForRenderer { corrected_query } => {
self.corrected_query = Some(corrected_query); self.corrected_query = Some(corrected_query);
} }

View file

@ -207,11 +207,9 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
&mut map_res, &mut map_res,
); );
} }
response::YouTubeListItem::ContinuationItemRenderer { response::YouTubeListItem::ContinuationItemRenderer(ep) => {
continuation_endpoint,
} => {
if ctoken.is_none() { if ctoken.is_none() {
ctoken = Some(continuation_endpoint.continuation_command.token); ctoken = ep.continuation_endpoint.into_token();
} }
} }
_ => {} _ => {}

View file

@ -208,11 +208,10 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
) )
}); });
let comment_ctoken = comment_ctoken_section.map(|s| { let comment_ctoken = comment_ctoken_section.and_then(|s| {
s.continuation_item_renderer s.continuation_item_renderer
.continuation_endpoint .continuation_endpoint
.continuation_command .into_token()
.token
}); });
let (owner, description, is_ccommons) = match secondary_info { let (owner, description, is_ccommons) = match secondary_info {
@ -333,7 +332,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
.sub_menu_items; .sub_menu_items;
items items
.try_swap_remove(1) .try_swap_remove(1)
.map(|c| c.service_endpoint.continuation_command.token) .and_then(|c| c.service_endpoint.into_token())
}); });
Ok(MapResult { Ok(MapResult {
@ -453,7 +452,9 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
} }
} }
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => { response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
ctoken = Some(cont.token()); if ctoken.is_none() {
ctoken = cont.into_token();
}
} }
response::video_details::CommentListItem::CommentsHeaderRenderer { count_text } => { response::video_details::CommentListItem::CommentsHeaderRenderer { count_text } => {
comment_count = count_text comment_count = count_text
@ -520,7 +521,9 @@ fn map_replies(
)) ))
} }
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => { response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
reply_ctoken = Some(cont.token()); if reply_ctoken.is_none() {
reply_ctoken = cont.into_token();
}
None None
} }
_ => None, _ => None,

File diff suppressed because it is too large Load diff