fix: A/B test 22: commandExecutorCommand for playlist continuations
This commit is contained in:
parent
fcf27aa3b2
commit
e8acbfbbcf
12 changed files with 33753 additions and 36 deletions
|
|
@ -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\""))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
29478
testfiles/playlist/playlist_20250316_cmdexecutor.json
Normal file
29478
testfiles/playlist/playlist_20250316_cmdexecutor.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue