Compare commits

..

4 commits

Author SHA1 Message Date
449fc0128e
chore(release): release rustypipe v0.1.3
All checks were successful
Release / Release (push) Successful in 3m3s
CI / Test (push) Successful in 4m36s
2024-04-02 01:52:43 +02:00
490350fcfe
ci: dont run CI on pushed tags 2024-04-02 01:52:07 +02:00
b0331f7250
fix: parse new comment model (A/B#14 frameworkUpdates) 2024-04-02 01:49:43 +02:00
348c8523fe
revert: "fix: improve VecLogErr messages" (leads to infinite loop)
This reverts commit 9a652d851f.
2024-04-02 01:49:40 +02:00
15 changed files with 18242 additions and 138 deletions

View file

@ -1,5 +1,8 @@
name: CI
on: [push, pull_request]
on:
push:
branches: ["main"]
pull_request:
jobs:
Test:

View file

@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## [v0.1.3](https://code.thetadev.de/ThetaDev/rustypipe/compare/rustypipe/v0.1.2..v0.1.3) - 2024-04-01
### 🐛 Bug Fixes
- Parse new comment model (A/B#14 frameworkUpdates) - ([b0331f7](https://code.thetadev.de/ThetaDev/rustypipe/commit/b0331f7250f5d7d61a45209150739d2cb08b4280))
### ◀️ Revert
- "fix: improve VecLogErr messages" (leads to infinite loop) - ([348c852](https://code.thetadev.de/ThetaDev/rustypipe/commit/348c8523fe847f2f6ce98317375a7ab65e778ed2))
## [v0.1.2](https://code.thetadev.de/ThetaDev/rustypipe/compare/rustypipe/v0.1.1..v0.1.2) - 2024-03-26
### 🐛 Bug Fixes

View file

@ -1,6 +1,6 @@
[package]
name = "rustypipe"
version = "0.1.2"
version = "0.1.3"
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -36,7 +36,7 @@ Client for the public YouTube / YouTube Music API (Innertube), inspired by
```toml
[dependencies]
rustypipe = "0.1.2"
rustypipe = "0.1.3"
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
```

View file

@ -13,6 +13,8 @@ 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,
)]
@ -31,10 +33,15 @@ pub enum ABTest {
LikeButtonViewmodel = 11,
ChannelPageHeader = 12,
MusicPlaylistTwoColumn = 13,
CommentsFrameworkUpdate = 14,
}
/// List of active A/B tests that are run when none is manually specified
const TESTS_TO_RUN: [ABTest; 2] = [ABTest::ChannelPageHeader, ABTest::MusicPlaylistTwoColumn];
const TESTS_TO_RUN: [ABTest; 3] = [
ABTest::ChannelPageHeader,
ABTest::MusicPlaylistTwoColumn,
ABTest::CommentsFrameworkUpdate,
];
#[derive(Debug, Serialize, Deserialize)]
pub struct ABTestRes {
@ -104,6 +111,7 @@ pub async fn run_test(
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,
}
.unwrap();
pb.inc(1);
@ -356,3 +364,20 @@ pub async fn music_playlist_two_column(rp: &RustyPipeQuery) -> Result<bool> {
.unwrap();
Ok(res.contains("\"musicResponsiveHeaderRenderer\""))
}
pub async fn comments_framework_update(rp: &RustyPipeQuery) -> Result<bool> {
let continuation =
"Eg0SC3dMZHBSN2d1S3k4GAYyJSIRIgt3TGRwUjdndUt5ODAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D";
let res = rp
.raw(
ClientType::Desktop,
"next",
&QCont {
context: rp.get_context(ClientType::Desktop, true, None).await,
continuation,
},
)
.await
.unwrap();
Ok(res.contains("\"frameworkUpdates\""))
}

View file

@ -592,7 +592,6 @@ be accomodated. There are also no mobile/TV header images available any more.
}
```
## [13] Music album/playlist 2-column layout
- **Encountered on:** 29.02.2024
@ -602,5 +601,149 @@ be accomodated. There are also no mobile/TV header images available any more.
![A/B test 13 screenshot](./_img/ab_13.png)
YouTube Music updated the layout of album and playlist pages. The new layout shows
the cover on the left side of the playlist content.
YouTube Music updated the layout of album and playlist pages. The new layout shows the
cover on the left side of the playlist content.
## [14] Comments Framework update
- **Encountered on:** 31.01.2024
- **Impact:** 🟢 Low
- **Endpoint:** next
- **Status:** Common (50%)
YouTube changed the data model for YouTube comments, now putting the content into a
seperate framework update object
```json
{
"frameworkUpdates": {
"onResponseReceivedEndpoints": [
{
"clickTrackingParams": "CAAQg2ciEwi64q3dmKGFAxWvy0IFHc14BKM=",
"reloadContinuationItemsCommand": {
"targetId": "comments-section",
"continuationItems": [
{
"commentThreadRenderer": {
"replies": {
"commentRepliesRenderer": {
"contents": [
{
"continuationItemRenderer": {
"trigger": "CONTINUATION_TRIGGER_ON_ITEM_SHOWN",
"continuationEndpoint": {
"clickTrackingParams": "CHgQvnUiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/next"
}
},
"continuationCommand": {
"token": "Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5TlRUOHV4REVqZ1lxeWJJRjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZw%3D%3D",
"request": "CONTINUATION_REQUEST_TYPE_WATCH_NEXT"
}
}
}
}
],
"trackingParams": "CHgQvnUiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"viewReplies": {
"buttonRenderer": {
"text": { "runs": [{ "text": "220 replies" }] },
"icon": { "iconType": "ARROW_DROP_DOWN" },
"trackingParams": "CHoQosAEIhMIuuKt3ZihhQMVr8tCBR3NeASj",
"iconPosition": "BUTTON_ICON_POSITION_TYPE_LEFT_OF_TEXT"
}
},
"hideReplies": {
"buttonRenderer": {
"text": { "runs": [{ "text": "220 replies" }] },
"icon": { "iconType": "ARROW_DROP_UP" },
"trackingParams": "CHkQ280EIhMIuuKt3ZihhQMVr8tCBR3NeASj",
"iconPosition": "BUTTON_ICON_POSITION_TYPE_LEFT_OF_TEXT"
}
},
"targetId": "comment-replies-item-UgyNTT8uxDEjgYqybIF4AaABAg"
}
},
"trackingParams": "CHYQwnUYywEiEwi64q3dmKGFAxWvy0IFHc14BKM=",
"renderingPriority": "RENDERING_PRIORITY_PINNED_COMMENT",
"isModeratedElqComment": false,
"commentViewModel": {
"commentViewModel": {
"commentId": "UgyNTT8uxDEjgYqybIF4AaABAg"
}
}
}
}
]
}
}
],
"entityBatchUpdate": {
"mutations": [
{
"entityKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAoKAE%3D",
"type": "ENTITY_MUTATION_TYPE_REPLACE",
"payload": {
"commentEntityPayload": {
"key": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAoKAE%3D",
"properties": {
"commentId": "UgyNTT8uxDEjgYqybIF4AaABAg",
"content": {
"content": "⚠️ Important notice: if you put any symbol immediately after markup, it will not work: *here is the comma*, without space.\n\nYou should leave space before and after , to make it work.\n\nSame for _underscore_, and -hyphen-.\n\nLeave space before opening and after closing underscore and hyphen. Put all dots and commas inside markup.",
"styleRuns": [
{
"startIndex": 135,
"length": 28,
"weightLabel": "FONT_WEIGHT_MEDIUM"
},
{
"startIndex": 267,
"length": 10,
"weightLabel": "FONT_WEIGHT_NORMAL",
"italic": true
},
{
"startIndex": 282,
"length": 7,
"weightLabel": "FONT_WEIGHT_NORMAL",
"strikethrough": "LINE_STYLE_SINGLE"
}
]
},
"publishedTime": "2 years ago (edited)",
"replyLevel": 0,
"authorButtonA11y": "@kibizoid",
"toolbarStateKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyAsKAE%3D",
"translateButtonEntityKey": "EhpVZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZyD_ASgB"
},
"author": {
"channelId": "UCUJfyiofeHQTmxKwZ6cCwIg",
"displayName": "@kibizoid",
"avatarThumbnailUrl": "https://yt3.ggpht.com/ytc/AIdro_nY2PkIyojDqs9Bk5RY6J90-U7wePswTYl799DNJQ=s88-c-k-c0x00ffffff-no-rj",
"isVerified": false,
"isCurrentUser": false,
"isCreator": false,
"isArtist": false
},
"avatar": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_nY2PkIyojDqs9Bk5RY6J90-U7wePswTYl799DNJQ=s88-c-k-c0x00ffffff-no-rj",
"width": 88,
"height": 88
}
]
}
}
}
}
}
]
}
}
}
```

View file

@ -3,7 +3,7 @@ use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkip
use super::{
video_item::YouTubeListRenderer, Alert, ChannelBadge, ContentRenderer, ContentsRenderer,
ContinuationActionWrap, ResponseContext, Thumbnails, TwoColumnBrowseResults,
ContinuationActionWrap, ImageView, ResponseContext, Thumbnails, TwoColumnBrowseResults,
};
use crate::serializer::text::{AttributedText, Text, TextComponent};
@ -224,12 +224,6 @@ pub(crate) struct PhAvatarView3 {
pub avatar_view_model: ImageView,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImageView {
pub image: Thumbnails,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PhMetadataView {

View file

@ -48,6 +48,7 @@ pub(crate) mod channel_rss;
pub(crate) use channel_rss::ChannelRss;
use std::borrow::Cow;
use std::collections::HashMap;
use std::marker::PhantomData;
use serde::{
@ -106,6 +107,12 @@ pub(crate) struct ThumbnailsWrap {
pub thumbnail: Thumbnails,
}
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImageView {
pub image: Thumbnails,
}
/// List of images in different resolutions.
/// Not only used for thumbnails, but also for avatars and banners.
#[derive(Default, Debug, Deserialize)]
@ -374,3 +381,87 @@ pub(crate) fn alerts_to_err(id: &str, alerts: Option<Vec<Alert>>) -> ExtractionE
.unwrap_or_default(),
}
}
// FRAMEWORK UPDATES
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FrameworkUpdates<T> {
pub entity_batch_update: EntityBatchUpdate<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EntityBatchUpdate<T> {
pub mutations: FrameworkUpdateMutations<T>,
}
/// List of update mutations that deserializes into a HashMap (entity_key => payload)
#[derive(Debug)]
pub(crate) struct FrameworkUpdateMutations<T> {
pub items: HashMap<String, T>,
pub warnings: Vec<String>,
}
impl<'de, T> Deserialize<'de> for FrameworkUpdateMutations<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SeqVisitor<T>(PhantomData<T>);
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum MutationOrError<T> {
#[serde(rename_all = "camelCase")]
Good {
entity_key: String,
payload: T,
},
Error(serde_json::Value),
}
impl<'de, T> Visitor<'de> for SeqVisitor<T>
where
T: Deserialize<'de>,
{
type Value = FrameworkUpdateMutations<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("sequence of entity mutations")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut items = HashMap::with_capacity(seq.size_hint().unwrap_or_default());
let mut warnings = Vec::new();
while let Some(value) = seq.next_element::<MutationOrError<T>>()? {
match value {
MutationOrError::Good {
entity_key,
payload,
} => {
items.insert(entity_key, payload);
}
MutationOrError::Error(value) => {
warnings.push(format!(
"error deserializing item: {}",
serde_json::to_string(&value).unwrap_or_default()
));
}
}
}
Ok(FrameworkUpdateMutations { items, warnings })
}
}
deserializer.deserialize_seq(SeqVisitor(PhantomData::<T>))
}
}

View file

@ -3,9 +3,8 @@
use serde::Deserialize;
use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError};
use crate::serializer::text::TextComponent;
use crate::serializer::{
text::{AccessibilityText, AttributedText, Text, TextComponents},
text::{AccessibilityText, AttributedText, Text, TextComponent, TextComponents},
MapResult,
};
@ -13,7 +12,10 @@ use super::{
url_endpoint::BrowseEndpointWrap, ContinuationEndpoint, ContinuationItemRenderer, Icon,
MusicContinuationData, Thumbnails,
};
use super::{ChannelBadge, ContentsRendererLogged, ResponseContext, YouTubeListItem};
use super::{
ChannelBadge, ContentsRendererLogged, FrameworkUpdates, ImageView, ResponseContext,
YouTubeListItem,
};
/*
#VIDEO DETAILS
@ -476,6 +478,7 @@ pub(crate) struct VideoComments {
/// - n*commentRenderer, continuationItemRenderer:
/// replies + continuation
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>,
pub framework_updates: Option<FrameworkUpdates<Payload>>,
}
/// Video comments continuation
@ -498,23 +501,13 @@ pub(crate) struct AppendComments {
#[serde(rename_all = "camelCase")]
pub(crate) enum CommentListItem {
/// Top-level comment
#[serde(rename_all = "camelCase")]
CommentThreadRenderer {
comment: Comment,
/// Continuation token to fetch replies
#[serde(default)]
replies: Replies,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
rendering_priority: CommentPriority,
},
CommentThreadRenderer(CommentThreadRenderer),
/// Reply comment
CommentRenderer(CommentRenderer),
/// Reply comment (A/B #14)
CommentViewModel(CommentViewModel),
/// Continuation token to fetch more comments
#[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
ContinuationItemRenderer(ContinuationItemVariants),
/// Header of the comment section (contains number of comments)
#[serde(rename_all = "camelCase")]
CommentsHeaderRenderer {
@ -524,6 +517,46 @@ pub(crate) enum CommentListItem {
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ContinuationItemVariants {
#[serde(rename_all = "camelCase")]
Ep {
continuation_endpoint: ContinuationEndpoint,
},
Btn {
button: ContinuationButton,
},
}
impl ContinuationItemVariants {
pub fn token(self) -> String {
match self {
ContinuationItemVariants::Ep {
continuation_endpoint,
} => continuation_endpoint,
ContinuationItemVariants::Btn { button } => button.button_renderer.command,
}
.continuation_command
.token
}
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentThreadRenderer {
/// Missing on the FrameworkUpdate data model (A/B #14)
pub comment: Option<Comment>,
pub comment_view_model: Option<CommentViewModelWrap>,
/// Continuation token to fetch replies
#[serde(default)]
pub replies: Replies,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub rendering_priority: CommentPriority,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Comment {
@ -564,7 +597,7 @@ pub(crate) struct CommentRenderer {
pub action_buttons: CommentActionButtons,
}
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[derive(Default, Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum CommentPriority {
/// Default rendering priority
@ -574,6 +607,26 @@ pub(crate) enum CommentPriority {
RenderingPriorityPinnedComment,
}
impl From<CommentPriority> for bool {
fn from(value: CommentPriority) -> Self {
matches!(value, CommentPriority::RenderingPriorityPinnedComment)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentViewModelWrap {
pub comment_view_model: CommentViewModel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentViewModel {
pub comment_id: String,
pub comment_key: String,
pub toolbar_state_key: String,
}
/// Does not contain replies directly but a continuation token
/// for fetching them.
#[derive(Default, Debug, Deserialize)]
@ -637,3 +690,85 @@ pub(crate) struct AuthorCommentBadgeRenderer {
/// Artist: `OFFICIAL_ARTIST_BADGE`
pub icon: Icon,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum Payload {
CommentEntityPayload(CommentEntityPayload),
#[serde(rename_all = "camelCase")]
EngagementToolbarStateEntityPayload {
heart_state: HeartState,
},
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentEntityPayload {
pub properties: CommentProperties,
#[serde(default)]
#[serde_as(as = "DefaultOnError")]
pub author: Option<CommentAuthor>,
pub toolbar: CommentToolbar,
#[serde(default)]
pub avatar: ImageView,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentProperties {
#[serde_as(as = "AttributedText")]
pub content: TextComponents,
pub published_time: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentAuthor {
pub channel_id: String,
pub display_name: String,
#[serde(default)]
pub is_verified: bool,
#[serde(default)]
pub is_artist: bool,
#[serde(default)]
pub is_creator: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommentToolbar {
pub like_count_notliked: String,
pub reply_count: String,
}
#[derive(Debug, Copy, Clone, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum HeartState {
ToolbarHeartStateUnhearted,
ToolbarHeartStateHearted,
}
impl From<HeartState> for bool {
fn from(value: HeartState) -> Self {
match value {
HeartState::ToolbarHeartStateUnhearted => false,
HeartState::ToolbarHeartStateHearted => true,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationButton {
pub button_renderer: ContinuationButtonRenderer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContinuationButtonRenderer {
pub command: ContinuationEndpoint,
}

View file

@ -0,0 +1,691 @@
---
source: src/client/video_details.rs
expression: map_res.c
---
Paginator(
count: Some(20617),
items: [
Comment(
id: "UgyNTT8uxDEjgYqybIF4AaABAg",
text: RichText([
Text(
text: "⚠\u{fe0f} Important notice: if you put any symbol immediately after markup, it will not work: *here is the comma*, without space.\n\nYou should leave space before and after , to make it work.\n\nSame for _underscore_, and -hyphen-.\n\nLeave space before opening and after closing underscore and hyphen. Put all dots and commas inside markup.",
),
]),
author: Some(ChannelTag(
id: "UCUJfyiofeHQTmxKwZ6cCwIg",
name: "@kibizoid",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_nY2PkIyojDqs9Bk5RY6J90-U7wePswTYl799DNJQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago (edited)",
like_count: Some(293),
reply_count: 220,
replies: Paginator(
count: Some(220),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5TlRUOHV4REVqZ1lxeWJJRjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3lOVFQ4dXhERWpnWXF5YklGNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: true,
hearted: true,
),
Comment(
id: "UgycWgNOoon0A4EV9LZ4AaABAg",
text: RichText([
Text(
text: "Me: tests out fonts\nFriend: Why are you doing this?\nMe: my goals are beyond your understanding",
),
]),
author: Some(ChannelTag(
id: "UCr0PeEY_am9P-GobbfvKECw",
name: "@userfjdrg",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/Zr2PmQsrD4obL2n5HS18X3jKXGJ-HFjIJS_OcZv4I5VAk5HuLRCpzFprY5Hh7n23-FCURVJi=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago",
like_count: Some(80),
reply_count: 34,
replies: Paginator(
count: Some(34),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5Y1dnTk9vb24wQTRFVjlMWjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3ljV2dOT29vbjBBNEVWOUxaNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "Ugy5iq4M1c3WS3lGmih4AaABAg",
text: RichText([
Text(
text: "To-do list\n• be dumb\n• get kicked out when i can legally live alone\n• spend money on pointless things",
),
]),
author: Some(ChannelTag(
id: "UCDB5XvpUB8cEvjbWewlp28w",
name: "@T0r0xFan",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/PPL5vj6-pXFpaLa41yet34OHGcEYt06WPQLmruaiFJSM0eLmn9ZQW0QgTtdafDBO-kNy2oukVA=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago (edited)",
like_count: Some(48),
reply_count: 22,
replies: Paginator(
count: Some(22),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5NWlxNE0xYzNXUzNsR21paDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3k1aXE0TTFjM1dTM2xHbWloNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "UgxqDIVVcoigjtx4Dtl4AaABAg",
text: RichText([
Text(
text: "omg thank you! Ive been looking for this tutorial for a year forever",
),
]),
author: Some(ChannelTag(
id: "UCxa4xER0-cFbcIYp0ZIeVaw",
name: "@LunasVibe",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/P5Io1kQb7GRwe4pgNsaYFEm30hDl_T7Tp5rZo7aYWFkqbV6Yp_lCYVuaaK7O3SEsnIX_5iC1Hw=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgxDQfVQdYaWR-VUM-94AaABAg",
text: RichText([
Text(
text: "tysm\ni finally learned it\nother channel never go straight to the point",
),
]),
author: Some(ChannelTag(
id: "UC8cojSRuyZT74Bs_b5AecTA",
name: "@Bp_bts_skz_for_life",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/0zFBRYv8wo2JqzkyMk29xgC8zD1nKYNSSoD3Zo9XP8t9rHrbTYEEt0gdu0O3XS7Scpza3JJKog=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago (edited)",
like_count: Some(1),
reply_count: 1,
replies: Paginator(
count: Some(1),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd4RFFmVlFkWWFXUi1WVU0tOTRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3hEUWZWUWRZYVdSLVZVTS05NEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgxFvrmwec-jmfQyGRR4AaABAg",
text: RichText([
Text(
text: "I like how this was straight to the point. Unlike other channels lol Thank you!",
),
]),
author: Some(ChannelTag(
id: "UCCyIVS_s1-jA48pPft8AifA",
name: "@ishouldbesleepingalready",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/N90y_XdoDP6Rd1v6Z5OpDH8jMtvqpU1qnF6DJoIL6qcLiWfZK7ok8u_IxqSxJazaQH6oqhEbqA=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago",
like_count: Some(241),
reply_count: 65,
replies: Paginator(
count: Some(65),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd4RnZybXdlYy1qbWZReUdSUjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3hGdnJtd2VjLWptZlF5R1JSNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugy-3OYEcwxkvyrrCqN4AaABAg",
text: RichText([
Text(
text: "To the person who is reading this: You\'re intelligent and smart, stay safe",
),
]),
author: Some(ChannelTag(
id: "UCQklgcA8quxZm5pgNAsVJAQ",
name: "@blocking948",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_nAg9bEjW4otWlryJwqAgiDRLzy8ZX-ROqkDY1ksQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "3 years ago (edited)",
like_count: Some(711),
reply_count: 250,
replies: Paginator(
count: Some(250),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5LTNPWUVjd3hrdnlyckNxTjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3ktM09ZRWN3eGt2eXJyQ3FONEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "Ugylw3ss_xv9svWbRud4AaABAg",
text: RichText([
Text(
text: " life could be a dream, life could be a dream ",
),
]),
author: Some(ChannelTag(
id: "UCSyjdP7Duhns4Ybncy6ObZA",
name: "@malarchee0899",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/dSwRzOfoKrN4yc58uhFlIyqBXmbi6B14-On-wEEM_S6Nr6aDHTkG-xVkI1-u-uBwqKqodEgrMro=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "13 days ago",
like_count: Some(2),
reply_count: 1,
replies: Paginator(
count: Some(1),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5bHczc3NfeHY5c3ZXYlJ1ZDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3lsdzNzc194djlzdldiUnVkNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgydXobRB0F5dW1KVsF4AaABAg",
text: RichText([
Text(
text: "Woah! thank you for showing me this I really needed it!",
),
]),
author: Some(ChannelTag(
id: "UC9f9uJgwsCBBHA4CioIzdkA",
name: "@fatimagarcia3162",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/cKmBVKqq9mkW4F355y3UtUw4POwTWKi-0LUYLDx85vffRd7pU-LECXvudUrHH_9qobo6A1kM=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgwmFn6ejKltcZ_BZvl4AaABAg",
text: RichText([
Text(
text: "The fitness gram pacer test is a multistage aerobic capacity test that progressively gets more difficult as it continues.",
),
]),
author: Some(ChannelTag(
id: "UCIymYi-_AJ10pYrh8sqTBTg",
name: "@No-du9is",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_lPWhGGvIIA08s4u_-Lwyx88rGSRksOFeYHipE=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago",
like_count: Some(22),
reply_count: 12,
replies: Paginator(
count: Some(12),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd3bUZuNmVqS2x0Y1pfQlp2bDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3dtRm42ZWpLbHRjWl9CWnZsNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "UgxtXH6bWRWm8ahavfR4AaABAg",
text: RichText([
Text(
text: "YouTube got a new update(or probably it\'s a bug) and for that it\'s not showing bold/strikethrough/italic on the app but it\'s showing on other places.",
),
]),
author: Some(ChannelTag(
id: "UCyaflzzOH0GC683DlQ-fzwg",
name: "@HaruXen",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/YMmDD0zp5wT6l5ozqVMEMuqm5W07QFqmMHzOJ9QKGnSf9xpgEQ0rznstfXlBDxlFpLIrltQxRg=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "3 years ago",
like_count: Some(167),
reply_count: 337,
replies: Paginator(
count: Some(337),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd4dFhINmJXUldtOGFoYXZmUjRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3h0WEg2YldSV204YWhhdmZSNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: true,
pinned: false,
hearted: false,
),
Comment(
id: "UgyHg3XnjBV935da_Lh4AaABAg",
text: RichText([
Text(
text: "omg it works i actuallly cant believe this ive been wanting to do this for ages thankyou so much!",
),
]),
author: Some(ChannelTag(
id: "UCFL5d8rMCfbxppODSbRLOgQ",
name: "@Auf-dem-weg-zum-sieg",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/oi4vbSV3Cx9G97QcHkSMWL98LksC6rnTLoq93T5sOO8MNuZPXWEXq9Nqkp8XYF93L2WklHADmNY=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "4 days ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgxIL5emXyn42htlfZZ4AaABAg",
text: RichText([
Text(
text: "I did know how to do that writing where the text is highlighted before, and now after I watched this video I knew even how to write those styles of text.",
),
]),
author: Some(ChannelTag(
id: "UChkVaXCYN_QcaE50zETAMOg",
name: "@CasamTheAnimator",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ctUbv8QpWPZRZumEBTVhlSSxg0JfiyvJ40nrWj_0ivOy5s6OoPK7iNp01diskRLs1Hig4ZE82w=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "3 weeks ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgwMKY-89XCdCVB9bXp4AaABAg",
text: RichText([
Text(
text: "Nobody asked for, but everyone needed",
),
]),
author: Some(ChannelTag(
id: "UCDezbPSXn3awzhxVm7qhGtg",
name: "@0_Ed",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/4DWdgRIJ0lEV-e4GZFrdf8MGxQBtML2aix2orKBt3iM6QBrh7Kg1ur1FZlyRmqWpWnRPRIex9w=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "3 years ago",
like_count: Some(12),
reply_count: 5,
replies: Paginator(
count: Some(5),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd3TUtZLTg5WENkQ1ZCOWJYcDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3dNS1ktODlYQ2RDVkI5YlhwNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "UgyfuG2sCDvgnRUYHJp4AaABAg",
text: RichText([
Text(
text: "me: types bold\n\nHaruTutorial: your bald",
),
]),
author: Some(ChannelTag(
id: "UCge96FdHXkARBjzPhdYl8Sg",
name: "@stargazeu",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/JoeW_qNuf9b6BusB3E6JShizqRLB4jR3NaTnsnzvpUQ1KW88OcS74_Sx1h6vjZiXK2uOxnrUNeY=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "3 years ago",
like_count: Some(42),
reply_count: 15,
replies: Paginator(
count: Some(15),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd5ZnVHMnNDRHZnblJVWUhKcDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3lmdUcyc0NEdmduUlVZSEpwNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "UgwAXndNNEa1h-VVIC94AaABAg",
text: RichText([
Text(
text: "the McDonalds dont feel like turning the Icecream machine on",
),
]),
author: Some(ChannelTag(
id: "UCkmY4kQ8e8gDRllV485Rd9g",
name: "@Flowershowrise",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/c2iMsq-wFjKRjTDqPy14UpMI1B9hNms4moW9H7xtPjOMI0vjaHwN94me23upYar-8CE3s6QkFw=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago",
like_count: Some(2),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgwwEBqareQ0tpsW7RR4AaABAg",
text: RichText([
Text(
text: "YOOO THIS IS SICK! THANK YOU MAN!",
),
]),
author: Some(ChannelTag(
id: "UChIbg4dGguUwzg7O-xmi57g",
name: "@ziaaaaa.",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/HEAdH93oAQu2ScXNmiKIISapv5O9dKSVLuT3gD1zJhSgHqTaptL7JPun6A5GZqg58_C75_OPkQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgxnFMLrpvbCWzHidml4AaABAg",
text: RichText([
Text(
text: "Someone must honor him , this man is the best , no , he is a LEGEND . We must all thank him for his video and for getting to the point immediately.",
),
]),
author: Some(ChannelTag(
id: "UCeGJuvHZqqebHTE_Kz2zyug",
name: "@Dahackabarade",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/csf-cEr2z4uWg8ZpVEPqgS2D2ZUHKBAJWnIbnzQCRtAlioSlUbtQZAyx76tnyfpXpixrsKke6DE=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago (edited)",
like_count: Some(11),
reply_count: 5,
replies: Paginator(
count: Some(5),
items: [],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYygwEaUBIaVWd4bkZNTHJwdmJDV3pIaWRtbDRBYUFCQWciAggAKhhVQ3lhZmx6ek9IMEdDNjgzRGxRLWZ6d2cyC1FpcDFWa1R1TTcwQAFICoIBAggBQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3huRk1McnB2YkNXekhpZG1sNEFhQUJBZw%3D%3D"),
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "UgwCIwmF6synP7UF_wV4AaABAg",
text: RichText([
Text(
text: "Never gonna give you up. Im gonna let u down",
),
]),
author: Some(ChannelTag(
id: "UCyrDrBrWvXwIhf2s2F1dq-Q",
name: "@imnotjust...2326",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/Clzb1GD_KnRm9u7mOAN165HZO_H0jhXQlRG8YvEjqkDuBUNibGkclRyRZIdhi-yJhC4hHorGLQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 years ago",
like_count: Some(14),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: true,
),
Comment(
id: "Ugyb5Wy91Yon69o3wLh4AaABAg",
text: RichText([
Text(
text: "Thank you for being A Legend No, The Goat Lets go dude",
),
]),
author: Some(ChannelTag(
id: "UCPCgaC_EJlS5RpRRWPHWvKA",
name: "@gfghdgfghd6391",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_n0lpwvjOP9HO_XHxzInwQoqQ7qIXeR0SqZVbCE=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "1 month ago",
like_count: Some(0),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
],
ctoken: Some("Eg0SC1FpcDFWa1R1TTcwGAYy4gIKuAJnZXRfcmFua2VkX3N0cmVhbXMtLUNxWUJDSUFFRlJlMzBUZ2Ftd0VLbGdFSTJGOFFnQVFZQnlLTEFSdUNtdFZ5a3dZMFFzVVVvM3I0LUY0OWU2d3RGSGFjbDIxS0Nsd3M4ZFZNaGdDdm9VWFhac2NZNXVncURIaUNiQVpveUczUEh6MTRPQ0tJV1BZTm9PTnN6dlFPVDZkaFZXMGRiSlZNelJXSW5QTm5QY0pyTmhQbzAyT1ZuamlVcHJTTHc1UEZxVHFBRkxlYXEtSHQtdU5uZkp1SzItMXVhQkp2aWE3S183QzgzOURiekJhY2tFeVRzUUFRRkJJRkNJZ2dHQUFTQndpSElCQUJHQUFTQlFpb0lCZ0FFZ1VJaVNBWUFCSUhDSVVnRUFrWUFSSUhDSVFnRUFzWUFSZ0EiESILUWlwMVZrVHVNNzAwAHgBKBRCEGNvbW1lbnRzLXNlY3Rpb24%3D"),
endpoint: browse,
)

View file

@ -0,0 +1,351 @@
---
source: src/client/video_details.rs
expression: map_res.c
---
Paginator(
count: None,
items: [
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-GzThFMUcw",
text: RichText([
Text(
text: "Fact🙌🏻",
),
]),
author: Some(ChannelTag(
id: "UC4I0-MXGyTRsc1tsJrDMh2A",
name: "@Sadaf788",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/uIXOBEBIhYn6Y8cQHlhaGGnCqOqS8PI5YR_Cx28qR_Y_p1qkjHC8V68iwxfeJ20eQ3zp81owJ64=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(800),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-H295I5iMZ",
text: RichText([
Text(
text: "Facts",
),
]),
author: Some(ChannelTag(
id: "UCdJ0CAWWa1rRjRbVrQUrU_w",
name: "@Biggest_Onceu",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/dHe_Zmr-7EueUz0R-BbuFoGwRyNMXwa3gb_GJMgAie9yU5PM6LbgTlNJ1zivRxnjiFg2nrlF1Es=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(530),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-H73oLoHkI",
text: RichText([
Text(
text: "Faacttts",
),
]),
author: Some(ChannelTag(
id: "UCPPhfcNhQ768F0Hhk3-25hA",
name: "@neni996",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/t__fCiOIhKCg2BwaxaRog9F_a5uemd8rTEvwzWYl6WeLn-nN9xEW0FvxUtM0fQrh2Dj_6ENsGQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(412),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-H7nBbPD5z",
text: RichText([
Text(
text: "REAL",
),
]),
author: Some(ChannelTag(
id: "UCQyomFJDEQtC2lbQ6E7QUGA",
name: "@momolvs",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/nt6GjcwAs8NPgaF29pn-cHTPmPAwQC_e_lXQHGDjZJGSRKzsH4s3le8Wpg0ByAUvPwTSHWe0OA=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(312),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-H9s3LvStZ",
text: RichText([
Text(
text: "FR!!",
),
]),
author: Some(ChannelTag(
id: "UC9HOPOf3gD3aw6Ej9WZ-rYg",
name: "@user-vv9yp1fh8w",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_nqyUCWW7jqWrg_39XNQ18-acPouL6wyHeQnZOMbmlSa9x2YGWINkfU1DLcvaXw=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(187),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-HB52Dv3SL",
text: RichText([
Text(
text: "fato.",
),
]),
author: Some(ChannelTag(
id: "UCW6ua0VDEFz7SyVMX01fTCA",
name: "@millenatwice",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/rInaNzUF3JLL_pCNfZtZlf2cHipf1yM4grr8VGJRHocwOQiuq1x7kUVi24q3ydtDC0j8bqbw2vA=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(165),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-HDLrM1OPD",
text: RichText([
Text(
text: "For sure!! TWICE is always TWICE!! They always give GOOD MUSIC",
),
]),
author: Some(ChannelTag(
id: "UCqDSps4SV0v8Dzf8esr6ScQ",
name: "@Its_me_hi_good",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_nfh9rWK7_gae1YkUgKuq13G9OUpxQCqrXAAi1hfPkCvHeHORbq3DUTYm7b5eoy=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(341),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-HIAbm3Him",
text: RichText([
Text(
text: "Fr",
),
]),
author: Some(ChannelTag(
id: "UCFA4BaLyvM1DDNsFyE_BHqQ",
name: "@amanpreetbrar7836",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_n1M-xxgSLIqe17kDv-i-tPn23FT1ywabpRAQ=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(84),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-HIrpOKIi7",
text: RichText([
Text(
text: "Presave I got you and with youth on spotify",
),
]),
author: Some(ChannelTag(
id: "UCxMDESp088wGItVM4xXACgw",
name: "@RitaOnce9",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hVTumbLCpmtJw0c2mq1B-ES5W3kdYPqnNrtzEcUhxCoUN6dAutXc6exaPRnBMLM6Jw1ILPoBDg=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(112),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
Comment(
id: "Ugzu-t48vV9SjdeWIMh4AaABAg.A-Grr7qN9uaA-HKMOSBnLK",
text: RichText([
Text(
text: "real",
),
]),
author: Some(ChannelTag(
id: "UCqeOr9ddrs_d6OgboKjk6zw",
name: "@twiceupremacy",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_lpKYjxtRm1HSjv3tFvGwrvnRILmJoQrPTBBOFG=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: "2 months ago",
like_count: Some(75),
reply_count: 0,
replies: Paginator(
count: Some(0),
items: [],
ctoken: None,
endpoint: browse,
),
by_owner: false,
pinned: false,
hearted: false,
),
],
ctoken: Some("Eg0SC2hhZjY3ZUtGMHVvGAYy1wEKUGdldF9jb21tZW50X3dpdGhfcmVwbGllc19zdHJlYW0tLUNnZ0lnQVFWRjdmUk9CSUZDS0FnR0FFWUFDSU9DZ3dJeXUzdXJRWVE2Tkt2NGdFGlASGlVnenUtdDQ4dlY5U2pkZVdJTWg0QWFBQkFnIgIIACoYVUNhTzZUWXRsQzhVNXR0ejYyaFRyWmdnMgtoYWY2N2VLRjB1b0AASDKCAQIIASgKQi9jb21tZW50LXJlcGxpZXMtaXRlbS1VZ3p1LXQ0OHZWOVNqZGVXSU1oNEFhQUJBZw%3D%3D"),
endpoint: browse,
)

View file

@ -1,4 +1,4 @@
use std::fmt::Debug;
use std::{collections::HashMap, fmt::Debug};
use serde::Serialize;
@ -6,7 +6,7 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
ChannelTag, Chapter, Comment, VideoDetails, VideoItem,
ChannelTag, Chapter, Comment, Verification, VideoDetails, VideoItem,
},
param::Language,
serializer::MapResult,
@ -14,7 +14,7 @@ use crate::{
};
use super::{
response::{self, IconType},
response::{self, video_details::Payload, IconType},
ClientType, MapResponse, QContinuation, RustyPipeQuery, YTContext,
};
@ -391,44 +391,73 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
_vdata: Option<&str>,
) -> Result<MapResult<Paginator<Comment>>, ExtractionError> {
let received_endpoints = self.on_response_received_endpoints;
let mut warnings = received_endpoints.warnings;
let mut warnings = Vec::new();
let mut comments = Vec::new();
let mut comment_count = None;
let mut ctoken = None;
let mut mutations = if let Some(upd) = self.framework_updates {
let mut m = upd.entity_batch_update.mutations;
warnings.append(&mut m.warnings);
m.items
} else {
HashMap::new()
};
received_endpoints.c.into_iter().for_each(|citem| {
let mut items = citem.append_continuation_items_action.continuation_items;
warnings.append(&mut items.warnings);
items.c.into_iter().for_each(|item| match item {
response::video_details::CommentListItem::CommentThreadRenderer {
comment,
replies,
rendering_priority,
} => {
let mut res = map_comment(
comment.comment_renderer,
Some(replies),
rendering_priority,
lang,
);
comments.push(res.c);
warnings.append(&mut res.warnings);
response::video_details::CommentListItem::CommentThreadRenderer(thread) => {
if let Some(comment) = thread.comment {
comments.push(map_comment(
comment.comment_renderer,
Some(thread.replies),
thread.rendering_priority,
lang,
&mut warnings,
));
} else if let Some(vm) = thread.comment_view_model {
if let Some(c) = map_comment_vm(
vm.comment_view_model,
&mut mutations,
Some(thread.replies),
thread.rendering_priority,
lang,
&mut warnings,
) {
comments.push(c);
}
} else {
warnings.push(
"comment does not contain comment or commentViewModel field".to_owned(),
);
}
}
response::video_details::CommentListItem::CommentRenderer(comment) => {
let mut res = map_comment(
comments.push(map_comment(
comment,
None,
response::video_details::CommentPriority::RenderingPriorityUnknown,
lang,
);
comments.push(res.c);
warnings.append(&mut res.warnings);
&mut warnings,
));
}
response::video_details::CommentListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
ctoken = Some(continuation_endpoint.continuation_command.token);
response::video_details::CommentListItem::CommentViewModel(vm) => {
if let Some(c) = map_comment_vm(
vm,
&mut mutations,
None,
response::video_details::CommentPriority::RenderingPriorityUnknown,
lang,
&mut warnings,
) {
comments.push(c);
}
}
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
ctoken = Some(cont.token());
}
response::video_details::CommentListItem::CommentsHeaderRenderer { count_text } => {
comment_count = count_text
@ -471,87 +500,152 @@ fn map_recommendations(
}
}
fn map_replies(
replies: Option<response::video_details::Replies>,
lang: Language,
warnings: &mut Vec<String>,
) -> (Vec<Comment>, Option<String>) {
let mut reply_ctoken = None;
let replies = replies
.map(|replies| {
replies
.comment_replies_renderer
.contents
.into_iter()
.filter_map(|item| match item {
response::video_details::CommentListItem::CommentRenderer(comment) => {
Some(map_comment(
comment,
None,
response::video_details::CommentPriority::default(),
lang,
warnings,
))
}
response::video_details::CommentListItem::ContinuationItemRenderer(cont) => {
reply_ctoken = Some(cont.token());
None
}
_ => None,
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
(replies, reply_ctoken)
}
fn map_comment(
c: response::video_details::CommentRenderer,
replies: Option<response::video_details::Replies>,
priority: response::video_details::CommentPriority,
lang: Language,
) -> MapResult<Comment> {
let mut warnings = Vec::new();
warnings: &mut Vec<String>,
) -> Comment {
let (replies, reply_ctoken) = map_replies(replies, lang, warnings);
let mut reply_ctoken = None;
let replies = replies.map(|replies| {
replies
.comment_replies_renderer
.contents
.into_iter()
.filter_map(|item| match item {
response::video_details::CommentListItem::CommentRenderer(comment) => {
let mut res = map_comment(
comment,
None,
response::video_details::CommentPriority::default(),
lang,
);
warnings.append(&mut res.warnings);
Some(res.c)
}
response::video_details::CommentListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
reply_ctoken = Some(continuation_endpoint.continuation_command.token);
None
}
_ => None,
})
.collect::<Vec<_>>()
});
MapResult {
c: Comment {
id: c.comment_id,
text: c.content_text.into(),
author: match (c.author_endpoint, c.author_text) {
(Some(aep), Some(name)) => Some(ChannelTag {
id: aep.browse_endpoint.browse_id,
name,
avatar: c.author_thumbnail.into(),
verification: c
.author_comment_badge
.map(|b| b.author_comment_badge_renderer.icon.into())
.unwrap_or_default(),
subscriber_count: None,
}),
_ => None,
},
publish_date: timeago::parse_timeago_dt_or_warn(
lang,
&c.published_time_text,
&mut warnings,
),
publish_date_txt: c.published_time_text,
like_count: match c.vote_count {
Some(txt) => util::parse_numeric_or_warn(&txt, &mut warnings),
None => Some(0),
},
reply_count: c.reply_count as u32,
replies: replies
.map(|items| Paginator::new(Some(c.reply_count), items, reply_ctoken))
.unwrap_or_default(),
by_owner: c.author_is_channel_owner,
pinned: priority
== response::video_details::CommentPriority::RenderingPriorityPinnedComment,
hearted: c
.action_buttons
.comment_action_buttons_renderer
.creator_heart
.map(|h| h.creator_heart_renderer.is_hearted)
.unwrap_or_default(),
Comment {
id: c.comment_id,
text: c.content_text.into(),
author: match (c.author_endpoint, c.author_text) {
(Some(aep), Some(name)) => Some(ChannelTag {
id: aep.browse_endpoint.browse_id,
name,
avatar: c.author_thumbnail.into(),
verification: c
.author_comment_badge
.map(|b| b.author_comment_badge_renderer.icon.into())
.unwrap_or_default(),
subscriber_count: None,
}),
_ => None,
},
warnings,
publish_date: timeago::parse_timeago_dt_or_warn(lang, &c.published_time_text, warnings),
publish_date_txt: c.published_time_text,
like_count: match c.vote_count {
Some(txt) => util::parse_numeric_or_warn(&txt, warnings),
None => Some(0),
},
reply_count: c.reply_count as u32,
replies: Paginator::new(Some(c.reply_count), replies, reply_ctoken),
by_owner: c.author_is_channel_owner,
pinned: priority.into(),
hearted: c
.action_buttons
.comment_action_buttons_renderer
.creator_heart
.map(|h| h.creator_heart_renderer.is_hearted)
.unwrap_or_default(),
}
}
fn map_comment_vm(
vm: response::video_details::CommentViewModel,
mutations: &mut HashMap<String, response::video_details::Payload>,
replies: Option<response::video_details::Replies>,
priority: response::video_details::CommentPriority,
lang: Language,
warnings: &mut Vec<String>,
) -> Option<Comment> {
let (replies, reply_ctoken) = map_replies(replies, lang, warnings);
let ce = if let Some(Payload::CommentEntityPayload(ce)) = mutations.remove(&vm.comment_key) {
ce
} else {
warnings.push(format!(
"comment `{}` does not have entity payload (key: `{}`)",
vm.comment_id, vm.comment_key
));
return None;
};
let hearted = if let Some(Payload::EngagementToolbarStateEntityPayload { heart_state }) =
mutations.get(&vm.toolbar_state_key)
{
(*heart_state).into()
} else {
false
};
let mut parse_num = |s: &str| -> Option<u32> {
if s.is_empty() || s == " " {
Some(0)
} else {
util::parse_large_numstr_or_warn(s, lang, warnings)
}
};
let reply_count = parse_num(&ce.toolbar.reply_count).unwrap_or_default();
Some(Comment {
id: vm.comment_id,
text: ce.properties.content.into(),
by_owner: ce.author.as_ref().map(|a| a.is_creator).unwrap_or_default(),
author: ce.author.map(|a| ChannelTag {
id: a.channel_id,
name: a.display_name,
avatar: ce.avatar.image.into(),
verification: if a.is_artist {
Verification::Artist
} else if a.is_verified {
Verification::Verified
} else {
Verification::None
},
subscriber_count: None,
}),
like_count: parse_num(&ce.toolbar.like_count_notliked),
reply_count,
replies: Paginator::new(Some(reply_count.into()), replies, reply_ctoken),
publish_date: timeago::parse_timeago_dt_or_warn(
lang,
&ce.properties.published_time,
warnings,
),
publish_date_txt: ce.properties.published_time,
pinned: priority.into(),
hearted,
})
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
@ -614,6 +708,8 @@ mod tests {
#[rstest]
#[case::top("top")]
#[case::latest("latest")]
#[case::frameworkupd("20240401_frameworkupd")]
#[case::frameworkupd_reply("20240401_frameworkupd_reply")]
fn map_comments(#[case] name: &str) {
let json_path = path!(*TESTFILES / "video_details" / format!("comments_{name}.json"));
let json_file = File::open(json_path).unwrap();

View file

@ -20,6 +20,13 @@ where
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum GoodOrError<T> {
Good(T),
Error(serde_json::Value),
}
struct SeqVisitor<T>(PhantomData<T>);
impl<'de, T> Visitor<'de> for SeqVisitor<T>
@ -39,14 +46,16 @@ where
let mut values = Vec::with_capacity(seq.size_hint().unwrap_or_default());
let mut warnings = Vec::new();
loop {
match seq.next_element::<T>() {
Ok(val) => match val {
Some(val) => values.push(val),
None => break,
},
Err(e) => {
warnings.push(format!("error deserializing item: {e}"));
while let Some(value) = seq.next_element()? {
match value {
GoodOrError::<T>::Good(value) => {
values.push(value);
}
GoodOrError::<T>::Error(value) => {
warnings.push(format!(
"error deserializing item: {}",
serde_json::to_string(&value).unwrap_or_default()
));
}
}
}
@ -177,8 +186,8 @@ mod tests {
insta::assert_debug_snapshot!(res.items.warnings, @r###"
[
"error deserializing item: missing field `name` at line 1 column 40",
"error deserializing item: missing field `name` at line 1 column 73",
"error deserializing item: {\"xyz\":\"i2\"}",
"error deserializing item: {\"namra\":\"i4\"}",
]
"###);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff