feat: add history item dates, extend timeago parser

This commit is contained in:
ThetaDev 2025-01-03 19:15:28 +01:00
parent 65ada37214
commit 320a8c2c24
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
28 changed files with 6507 additions and 2160 deletions

View file

@ -7,11 +7,15 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
ChannelItem, VideoItem,
ChannelItem, HistoryItem, VideoItem,
},
serializer::MapResult,
};
use self::response::YouTubeListMapper;
use super::{MapRespOptions, QContinuation};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QHistorySearch<'a> {
@ -24,7 +28,7 @@ impl RustyPipeQuery {
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn history(&self) -> Result<Paginator<VideoItem>, Error> {
pub async fn history(&self) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
let request_body = QBrowse {
browse_id: "FEhistory",
};
@ -41,6 +45,34 @@ impl RustyPipeQuery {
.await
}
/// Get more YouTube history items from the given continuation token
#[tracing::instrument(skip(self), level = "error")]
pub async fn history_continuation<S: AsRef<str> + Debug>(
&self,
ctoken: S,
visitor_data: Option<&str>,
) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
let ctoken = ctoken.as_ref();
let request_body = QContinuation {
continuation: ctoken,
};
self.clone()
.authenticated()
.execute_request_ctx::<response::Continuation, _, _>(
ClientType::Desktop,
"history_continuation",
ctoken,
"browse",
&request_body,
MapRespOptions {
visitor_data,
..Default::default()
},
)
.await
}
/// Search the YouTube playback history of the current user
///
/// Requires authentication cookies.
@ -48,7 +80,7 @@ impl RustyPipeQuery {
pub async fn history_search<S: AsRef<str> + Debug>(
&self,
query: S,
) -> Result<Paginator<VideoItem>, Error> {
) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
let query = query.as_ref();
let request_body = QHistorySearch {
browse_id: "FEhistory",
@ -104,6 +136,65 @@ impl RustyPipeQuery {
}
}
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> {
let items = self
.contents
.two_column_browse_results_renderer
.contents
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData(
"twoColumnBrowseResultsRenderer empty".into(),
))?
.tab_renderer
.content
.section_list_renderer
.contents;
let mut map_res = MapResult {
warnings: items.warnings,
..Default::default()
};
let mut ctoken = None;
for item in items.c {
match item {
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
let mut mapper = YouTubeListMapper::<VideoItem>::new(ctx.lang);
mapper.map_response(contents);
mapper.conv_history_items(
header.map(|h| h.item_section_header_renderer.title),
&mut map_res,
);
}
response::YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
if ctoken.is_none() {
ctoken = Some(continuation_endpoint.continuation_command.token);
}
}
_ => {}
}
}
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
crate::model::paginator::ContinuationEndpoint::Browse,
true,
),
warnings: map_res.warnings,
})
}
}
impl MapResponse<Paginator<VideoItem>> for response::History {
fn map_response(
self,
@ -131,7 +222,7 @@ impl MapResponse<Paginator<VideoItem>> for response::History {
None,
mapper.items,
mapper.ctoken,
None,
ctx.visitor_data.map(str::to_owned),
crate::model::paginator::ContinuationEndpoint::Browse,
true,
),
@ -145,29 +236,47 @@ mod tests {
use std::{fs::File, io::BufReader};
use path_macro::path;
use rstest::rstest;
use crate::util::tests::TESTFILES;
use super::*;
#[rstest]
#[case::history("history")]
#[case::subscription_feed("subscription_feed")]
fn map_history(#[case] name: &str) {
let json_path = path!(*TESTFILES / "history" / format!("{name}.json"));
#[test]
fn map_history() {
let json_path = path!(*TESTFILES / "history" / "history.json");
let json_file = File::open(json_path).unwrap();
let history: response::History =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = history.map_response(&MapRespCtx::test("")).unwrap();
let map_res: MapResult<Paginator<HistoryItem<VideoItem>>> =
history.map_response(&MapRespCtx::test("")).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), map_res.c, {
insta::assert_ron_snapshot!(map_res.c, {
".items[].playback_date" => "[date]",
});
}
#[test]
fn map_subscription_feed() {
let json_path = path!(*TESTFILES / "history" / "subscription_feed.json");
let json_file = File::open(json_path).unwrap();
let history: response::History =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<VideoItem>> =
history.map_response(&MapRespCtx::test("")).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(map_res.c, {
".items[].publish_date" => "[date]",
});
}

View file

@ -811,6 +811,16 @@ impl RustyPipeBuilder {
self
}
/// Enable authentication for all requests
///
/// Depending on the client type RustyPipe uses either the authentication cookie or the
/// OAuth token to authenticate requests.
#[must_use]
pub fn authenticated(mut self) -> Self {
self.default_opts.auth = Some(true);
self
}
/// Disable authentication for all requests
#[must_use]
pub fn unauthenticated(mut self) -> Self {

View file

@ -113,12 +113,12 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
});
let mapped_top = mapper_top.conv_items::<TrackItem>();
let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
let mut mapped_other = mapper_other.group_items();
let mapped_trending = mapper_trending.conv_items::<TrackItem>();
let mapped_other = mapper_other.group_items();
let mut warnings = mapped_top.warnings;
warnings.append(&mut mapped_trending.warnings);
warnings.append(&mut mapped_other.warnings);
warnings.extend(mapped_trending.warnings);
warnings.extend(mapped_other.warnings);
Ok(MapResult {
c: MusicCharts {

View file

@ -1,3 +1,5 @@
use std::fmt::Debug;
use crate::{
client::{
response::{self, music_item::MusicListMapper},
@ -6,19 +8,19 @@ use crate::{
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
AlbumItem, ArtistItem, MusicPlaylistItem, TrackItem,
AlbumItem, ArtistItem, HistoryItem, MusicPlaylistItem, TrackItem,
},
serializer::MapResult,
};
use super::MapRespCtx;
use super::{MapRespCtx, MapRespOptions, QContinuation};
impl RustyPipeQuery {
/// Get a list of tracks from YouTube Music which the current user recently played
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_history(&self) -> Result<Paginator<TrackItem>, Error> {
pub async fn music_history(&self) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
let request_body = QBrowseParams {
browse_id: "FEmusic_history",
params: "oggECgIIAQ%3D%3D",
@ -36,6 +38,34 @@ impl RustyPipeQuery {
.await
}
/// Get more YouTube Music history items from the given continuation token
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_history_continuation<S: AsRef<str> + Debug>(
&self,
ctoken: S,
visitor_data: Option<&str>,
) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
let ctoken = ctoken.as_ref();
let request_body = QContinuation {
continuation: ctoken,
};
self.clone()
.authenticated()
.execute_request_ctx::<response::MusicContinuation, _, _>(
ClientType::Desktop,
"history_continuation",
ctoken,
"browse",
&request_body,
MapRespOptions {
visitor_data,
..Default::default()
},
)
.await
}
/// Get a list of YouTube Music artists which the current user subscribed to
///
/// Requires authentication cookies.
@ -99,11 +129,11 @@ impl RustyPipeQuery {
}
}
impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicHistory {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
let contents = match self.contents {
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
c.contents
@ -120,7 +150,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
} => secondary_contents.section_list_renderer,
};
let mut mapper = MusicListMapper::new(ctx.lang);
let mut map_res = MapResult::default();
for shelf in contents.contents {
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
@ -128,11 +158,11 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
} else {
continue;
};
let mut mapper = MusicListMapper::new(ctx.lang);
mapper.map_response(shelf.contents);
mapper.conv_history_items(shelf.title, &mut map_res);
}
let map_res = mapper.conv_items();
let ctoken = contents
.continuations
.into_iter()
@ -144,7 +174,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
None,
map_res.c,
ctoken,
None,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
true,
),
@ -177,6 +207,8 @@ mod tests {
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(map_res.c);
insta::assert_ron_snapshot!(map_res.c, {
".items[].playback_date" => "[date]",
});
}
}

View file

@ -6,8 +6,11 @@ use crate::model::{
traits::FromYtItem,
Comment, MusicItem, YouTubeItem,
};
use crate::model::{HistoryItem, TrackItem, VideoItem};
use crate::serializer::MapResult;
use self::response::YouTubeListItem;
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
use super::{
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
@ -95,38 +98,44 @@ fn map_ytm_paginator<T: FromYtItem>(
}
}
fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTubeListItem>> {
response
.on_response_received_actions
.and_then(|actions| {
actions
.into_iter()
.map(|action| action.append_continuation_items_action.continuation_items)
.reduce(|mut acc, mut items| {
acc.c.append(&mut items.c);
acc.warnings.append(&mut items.warnings);
acc
})
})
.or_else(|| {
response
.continuation_contents
.map(|contents| contents.rich_grid_continuation.contents)
})
.unwrap_or_default()
}
impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
let items = self
.on_response_received_actions
.and_then(|actions| {
actions
.into_iter()
.map(|action| action.append_continuation_items_action.continuation_items)
.reduce(|mut acc, mut items| {
acc.c.append(&mut items.c);
acc.warnings.append(&mut items.warnings);
acc
})
})
.or_else(|| {
self.continuation_contents
.map(|contents| contents.rich_grid_continuation.contents)
})
.unwrap_or_default();
let estimated_results = self.estimated_results;
let items = continuation_items(self);
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
mapper.map_response(items);
Ok(MapResult {
c: Paginator::new_ext(
self.estimated_results,
estimated_results,
mapper.items,
mapper.ctoken,
None,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
@ -201,7 +210,99 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
warnings: map_res.warnings,
})
}
}
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> {
let mut map_res = MapResult::default();
let mut ctoken = None;
let items = continuation_items(self);
for item in items.c {
match item {
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
mapper.map_response(contents);
mapper.conv_history_items(
header.map(|h| h.item_section_header_renderer.title),
&mut map_res,
);
}
response::YouTubeListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
if ctoken.is_none() {
ctoken = Some(continuation_endpoint.continuation_command.token);
}
}
_ => {}
}
}
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
warnings: map_res.warnings,
})
}
}
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
let mut map_res = MapResult::default();
let mut continuations = Vec::new();
let mut map_shelf = |shelf: response::music_item::MusicShelf| {
let mut mapper = MusicListMapper::new(ctx.lang);
mapper.map_response(shelf.contents);
mapper.conv_history_items(shelf.title, &mut map_res);
continuations.extend(shelf.continuations);
};
match self.continuation_contents {
Some(response::music_item::ContinuationContents::MusicShelfContinuation(shelf)) => {
map_shelf(shelf);
}
Some(response::music_item::ContinuationContents::SectionListContinuation(contents)) => {
for c in contents.contents {
if let response::music_item::ItemSection::MusicShelfRenderer(shelf) = c {
map_shelf(shelf);
}
}
}
_ => {}
}
let ctoken = continuations
.into_iter()
.next()
.map(|cont| cont.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
@ -213,11 +314,6 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
impl<T: FromYtItem> Paginator<T> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
// let mut q = query.as_ref().clone();
// if self.authenticated {
// q = q.authenticated();
// }
Ok(match &self.ctoken {
Some(ctoken) => {
let q = if self.authenticated {
@ -319,6 +415,36 @@ impl Paginator<Comment> {
}
}
impl Paginator<HistoryItem<VideoItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(
query
.as_ref()
.history_continuation(ctoken, self.visitor_data.as_deref())
.await?,
),
_ => None,
})
}
}
impl Paginator<HistoryItem<TrackItem>> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(
query
.as_ref()
.music_history_continuation(ctoken, self.visitor_data.as_deref())
.await?,
),
_ => None,
})
}
}
macro_rules! paginator {
($entity_type:ty) => {
impl Paginator<$entity_type> {
@ -400,6 +526,8 @@ macro_rules! paginator {
}
paginator!(Comment);
paginator!(HistoryItem<VideoItem>);
paginator!(HistoryItem<TrackItem>);
#[cfg(test)]
mod tests {

View file

@ -209,6 +209,14 @@ pub(crate) struct TextBox {
pub text: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SimpleHeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]

View file

@ -4,7 +4,7 @@ use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkip
use crate::{
model::{
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
HistoryItem, MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
},
param::Language,
serializer::{
@ -18,7 +18,7 @@ use super::{
url_endpoint::{
BrowseEndpointWrap, MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
},
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
ContentsRenderer, MusicContinuationData, SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
};
#[serde_as]
@ -39,6 +39,8 @@ pub(crate) enum ItemSection {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicShelf {
#[serde_as(as = "Option<Text>")]
pub title: Option<String>,
/// Playlist ID (only for playlists)
pub playlist_id: Option<String>,
pub contents: MapResult<Vec<MusicResponseItem>>,
@ -396,15 +398,7 @@ pub(crate) struct GridRenderer {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GridHeader {
pub grid_header_renderer: GridHeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GridHeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
pub grid_header_renderer: SimpleHeaderRenderer,
}
#[derive(Debug, Deserialize)]
@ -419,14 +413,6 @@ pub(crate) struct SimpleHeader {
pub music_header_renderer: SimpleHeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SimpleHeaderRenderer {
#[serde_as(as = "Text")]
pub title: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum TrackBadge {
@ -1257,6 +1243,26 @@ impl MusicListMapper {
warnings: self.warnings,
}
}
pub fn conv_history_items(
self,
date_txt: Option<String>,
res: &mut MapResult<Vec<HistoryItem<TrackItem>>>,
) {
res.warnings.extend(self.warnings);
res.c.extend(
self.items
.into_iter()
.filter_map(TrackItem::from_ytm_item)
.map(|item| HistoryItem {
item,
playback_date: date_txt.as_deref().and_then(|s| {
timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings)
}),
playback_date_txt: date_txt.clone(),
}),
);
}
}
/// Map TextComponents containing artist names to a list of artists and a 'Various Artists' flag

View file

@ -4,9 +4,12 @@ use serde_with::{
};
use time::OffsetDateTime;
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
use super::{
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
Thumbnails,
};
use crate::{
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
model::{Channel, ChannelItem, ChannelTag, HistoryItem, PlaylistItem, VideoItem, YouTubeItem},
param::Language,
serializer::{
text::{AttributedText, Text, TextComponent},
@ -63,6 +66,7 @@ pub(crate) enum YouTubeListItem {
/// GridRenderer: contains videos on channel page
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
ItemSectionRenderer {
header: Option<ItemSectionHeader>,
#[serde(alias = "items")]
contents: MapResult<Vec<YouTubeListItem>>,
},
@ -294,6 +298,12 @@ pub(crate) struct YouTubeListRenderer {
pub contents: MapResult<Vec<YouTubeListItem>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ItemSectionHeader {
pub item_section_header_renderer: SimpleHeaderRenderer,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -833,7 +843,7 @@ impl YouTubeListMapper<YouTubeItem> {
YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content);
}
YouTubeListItem::ItemSectionRenderer { mut contents } => {
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it));
}
@ -881,7 +891,7 @@ impl YouTubeListMapper<VideoItem> {
YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content);
}
YouTubeListItem::ItemSectionRenderer { mut contents } => {
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it));
}
@ -893,6 +903,23 @@ impl YouTubeListMapper<VideoItem> {
self.warnings.append(&mut res.warnings);
res.c.into_iter().for_each(|item| self.map_item(item));
}
pub(crate) fn conv_history_items(
self,
date_txt: Option<String>,
res: &mut MapResult<Vec<HistoryItem<VideoItem>>>,
) {
res.warnings.extend(self.warnings);
res.c.extend(self.items.into_iter().map(|item| {
HistoryItem {
item,
playback_date: date_txt.as_deref().and_then(|s| {
timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings)
}),
playback_date_txt: date_txt.clone(),
}
}));
}
}
impl YouTubeListMapper<PlaylistItem> {
@ -916,7 +943,7 @@ impl YouTubeListMapper<PlaylistItem> {
YouTubeListItem::RichItemRenderer { content } => {
self.map_item(*content);
}
YouTubeListItem::ItemSectionRenderer { mut contents } => {
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it));
}

View file

@ -5,240 +5,260 @@ expression: map_res.c
Paginator(
count: None,
items: [
VideoItem(
id: "mPshy_DWxfo",
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
duration: Some(6),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACMQBEG5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLDnRYKBX4qMlA54i-q3W7w1WvGApg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCPYBEIoBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDza_6r3345q6SBZvGm292mOobNPg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCNACELwBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDySwxxAy2hfw2YcAKs6ERLhzPTkQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCM7OXM6t80a3e3tzQDWxwEA",
name: "Ari",
avatar: [
HistoryItem(
item: VideoItem(
id: "mPshy_DWxfo",
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
duration: Some(6),
thumbnail: [
Thumbnail(
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACMQBEG5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLDnRYKBX4qMlA54i-q3W7w1WvGApg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCPYBEIoBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDza_6r3345q6SBZvGm292mOobNPg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCNACELwBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDySwxxAy2hfw2YcAKs6ERLhzPTkQ",
width: 336,
height: 188,
),
],
verification: none,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(15),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
channel: Some(ChannelTag(
id: "UCM7OXM6t80a3e3tzQDWxwEA",
name: "Ari",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: none,
subscriber_count: None,
)),
publish_date: None,
publish_date_txt: None,
view_count: Some(15),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
playback_date: "[date]",
playback_date_txt: Some("Yesterday"),
),
VideoItem(
id: "SRWatgS077k",
name: "My Time at \"Camp Operetta\"",
duration: Some(578),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBgxonLRY-4QQ1-jR3Xen-fAZcHHQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBOk1abznwO5Bm0_m5YXMFkU0JSog",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLCQt7cAJuE-W8t1TnQnSe5EVbsw8A",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCGwu0nbY2wSkW8N-cghnLpA",
name: "JaidenAnimations",
avatar: [
HistoryItem(
item: VideoItem(
id: "SRWatgS077k",
name: "My Time at \"Camp Operetta\"",
duration: Some(578),
thumbnail: [
Thumbnail(
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBgxonLRY-4QQ1-jR3Xen-fAZcHHQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBOk1abznwO5Bm0_m5YXMFkU0JSog",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLCQt7cAJuE-W8t1TnQnSe5EVbsw8A",
width: 336,
height: 188,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(23907328),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("What can I say other than that was one heck of a time\n\nScribble Showdown Tickets: https://www.scribbleshowdown.com/\n\n\n♥ The Team ♥\nDenny: https://www.instagram.com/90percentknuckles/\nAtrox:..."),
channel: Some(ChannelTag(
id: "UCGwu0nbY2wSkW8N-cghnLpA",
name: "JaidenAnimations",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: None,
publish_date_txt: None,
view_count: Some(23907328),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("What can I say other than that was one heck of a time\n\nScribble Showdown Tickets: https://www.scribbleshowdown.com/\n\n\n♥ The Team ♥\nDenny: https://www.instagram.com/90percentknuckles/\nAtrox:..."),
),
playback_date: "[date]",
playback_date_txt: Some("Yesterday"),
),
VideoItem(
id: "kTxlkDoqArA",
name: "Wie Cartoons Früher gemacht wurden!",
duration: Some(283),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAgu_Ad1pFCsa3jINV1ocaVOQWOXg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDkOVQbyZlrZ_jbdkSzUd5RiobObA",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA5cnUH03I2lg1-FOJ01njh8UOJEw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCxTHCMaxURhapisCMBv8y0A",
name: "Plankton",
avatar: [
HistoryItem(
item: VideoItem(
id: "kTxlkDoqArA",
name: "Wie Cartoons Früher gemacht wurden!",
duration: Some(283),
thumbnail: [
Thumbnail(
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAgu_Ad1pFCsa3jINV1ocaVOQWOXg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDkOVQbyZlrZ_jbdkSzUd5RiobObA",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA5cnUH03I2lg1-FOJ01njh8UOJEw",
width: 336,
height: 188,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(390010),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("Folgt mir auf Instagram!\nhttps://instagram.com/plankton.gif \n\nÜBER DEN KANAL:\nRede viel wenn der Tag lang ist"),
channel: Some(ChannelTag(
id: "UCxTHCMaxURhapisCMBv8y0A",
name: "Plankton",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: None,
publish_date_txt: None,
view_count: Some(390010),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("Folgt mir auf Instagram!\nhttps://instagram.com/plankton.gif \n\nÜBER DEN KANAL:\nRede viel wenn der Tag lang ist"),
),
playback_date: "[date]",
playback_date_txt: Some("Yesterday"),
),
VideoItem(
id: "oIVSKQ8NMqk",
name: "What I learned on highschool swim",
duration: Some(620),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAEzpr1xBI-8jJwZz72NHj9VKyefA",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBNzC8nvKtO7fmqzavWemou7QOLOg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA1_VgkVeq4ELmrQ8a4vhtJhg6TMA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
name: "illymation",
avatar: [
HistoryItem(
item: VideoItem(
id: "oIVSKQ8NMqk",
name: "What I learned on highschool swim",
duration: Some(620),
thumbnail: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAEzpr1xBI-8jJwZz72NHj9VKyefA",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBNzC8nvKtO7fmqzavWemou7QOLOg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA1_VgkVeq4ELmrQ8a4vhtJhg6TMA",
width: 336,
height: 188,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(6491367),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("okay, so I wasn\'t the BEST ... but I tried my best!\n▶ Black Friday Merch sale: https://www.hereforthechaos.com\n▶ SNEAK PEEKS ON PATREON! http://patreon.com/illymation\n\n▶ BG ARTIST: Ingrid..."),
channel: Some(ChannelTag(
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
name: "illymation",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: None,
publish_date_txt: None,
view_count: Some(6491367),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("okay, so I wasn\'t the BEST ... but I tried my best!\n▶ Black Friday Merch sale: https://www.hereforthechaos.com\n▶ SNEAK PEEKS ON PATREON! http://patreon.com/illymation\n\n▶ BG ARTIST: Ingrid..."),
),
playback_date: "[date]",
playback_date_txt: Some("Yesterday"),
),
VideoItem(
id: "X30eFeqrHJo",
name: "My Last Week of University!",
duration: Some(659),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAE7jwC0MudkKK-I1nyGvCheljvtQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLAfIZ4htcNHP3RWahpR4XM7U-UTFQ",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDYMLObTeyxHK_PewK4Rwk3V-2KGw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC6a8lp6vaCMhUVXPyynhjUA",
name: "Ruby Granger",
avatar: [
HistoryItem(
item: VideoItem(
id: "X30eFeqrHJo",
name: "My Last Week of University!",
duration: Some(659),
thumbnail: [
Thumbnail(
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAE7jwC0MudkKK-I1nyGvCheljvtQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLAfIZ4htcNHP3RWahpR4XM7U-UTFQ",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDYMLObTeyxHK_PewK4Rwk3V-2KGw",
width: 336,
height: 188,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(132844),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("This first year has been somewhat hectic, and certainly was tricky in the first semester; however, I have enjoyed it immensely and, as I said, am sad that this term has now ended. I am returning..."),
channel: Some(ChannelTag(
id: "UC6a8lp6vaCMhUVXPyynhjUA",
name: "Ruby Granger",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: None,
publish_date_txt: None,
view_count: Some(132844),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("This first year has been somewhat hectic, and certainly was tricky in the first semester; however, I have enjoyed it immensely and, as I said, am sad that this term has now ended. I am returning..."),
),
playback_date: "[date]",
playback_date_txt: Some("Yesterday"),
),
],
ctoken: Some("4qmFsgJMEglGRWhpc3RvcnkaKENBSjZHbmx5WVRWb2QyOU9RMmR6U1RSaGNXMTFkMWxSTms4M05WcFKaAhRicm93c2UtZmVlZEZFaGlzdG9yeQ%3D%3D"),

View file

@ -1331,3 +1331,15 @@ pub struct MusicSearchSuggestion {
/// Suggested music items
pub items: Vec<MusicItem>,
}
/// YouTube playback history entry
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct HistoryItem<T> {
/// History item
pub item: T,
/// Playback date
pub playback_date: Option<Date>,
/// Textual playback date
pub playback_date_txt: Option<String>,
}

View file

@ -1,4 +1,4 @@
use time::{Date, Month, OffsetDateTime};
use time::{Date, Duration, Month, OffsetDateTime};
/// Shift a date by the given number of months.
/// Ambiguous month-ends are shifted backwards as necessary.
@ -25,6 +25,11 @@ pub fn shift_years(date: Date, years: i32) -> Date {
shift_months(date, years * 12)
}
pub fn shift_weeks_mo(date: Date, weeks: i32) -> Date {
let d = date + Duration::weeks(weeks.into());
Date::from_iso_week_date(d.year(), d.iso_week(), time::Weekday::Monday).unwrap()
}
/// Get the current datetime without milli/micro/nanoseconds
pub fn now_sec() -> OffsetDateTime {
OffsetDateTime::now_utc()

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ mod protobuf;
pub mod dictionary;
pub mod timeago;
pub use date::{now_sec, shift_months, shift_years};
pub use date::{now_sec, shift_months, shift_weeks_mo, shift_years};
pub use protobuf::{string_from_pb, ProtoBuilder};
use std::{

View file

@ -61,6 +61,8 @@ pub enum TimeUnit {
Week,
Month,
Year,
LastWeek,
LastWeekday,
}
/// Value of a parsed TimeAgo token, used in the dictionary
@ -86,10 +88,17 @@ impl TimeUnit {
TimeUnit::Week => 7 * 24 * 3600,
TimeUnit::Month => 30 * 24 * 3600,
TimeUnit::Year => 365 * 24 * 3600,
TimeUnit::LastWeekday | TimeUnit::LastWeek => 0,
}
}
}
impl TaToken {
fn into_timeago(self) -> Option<TimeAgo> {
self.unit.map(|unit| TimeAgo { n: self.n, unit })
}
}
impl TimeAgo {
fn secs(self) -> u32 {
u32::from(self.n) * self.unit.secs()
@ -119,6 +128,17 @@ impl From<TimeAgo> for OffsetDateTime {
match ta.unit {
TimeUnit::Month => ts.replace_date(util::shift_months(ts.date(), -i32::from(ta.n))),
TimeUnit::Year => ts.replace_date(util::shift_years(ts.date(), -i32::from(ta.n))),
TimeUnit::LastWeek => {
ts.replace_date(util::shift_weeks_mo(ts.date(), -i32::from(ta.n)))
}
TimeUnit::LastWeekday => ts.replace_date(
Date::from_iso_week_date(
ts.year(),
ts.iso_week(),
time::Weekday::Monday.nth_next(ta.n),
)
.unwrap(),
),
_ => ts - Duration::from(ta),
}
}
@ -139,7 +159,7 @@ fn filter_datestr(string: &str) -> String {
.to_lowercase()
.chars()
.filter_map(|c| {
if matches!(c, '\u{200b}' | '.') || c.is_ascii_digit() {
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
None
} else if c == '-' {
Some(' ')
@ -249,57 +269,86 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDa
let nums = util::parse_numeric_vec::<u16>(textual_date);
match nums.len() {
0 => match TaTokenParser::new(&entry, by_char, true, &filtered_str).next() {
Some(timeago) => Some(ParsedDate::Relative(timeago)),
None => TaTokenParser::new(&entry, by_char, false, &filtered_str)
.next()
.map(ParsedDate::Relative),
},
1 => TaTokenParser::new(&entry, by_char, false, &filtered_str)
.next()
.map(|timeago| ParsedDate::Relative(timeago * nums[0] as u8)),
2..=3 => {
if nums.len() == entry.date_order.len() {
let mut y: Option<u16> = None;
let mut m: Option<u16> = None;
let mut d: Option<u16> = None;
nums.iter()
.enumerate()
.for_each(|(i, n)| match entry.date_order[i] {
DateCmp::Y => y = Some(*n),
DateCmp::M => m = Some(*n),
DateCmp::D => d = Some(*n),
});
// Chinese/Japanese dont use textual months
if m.is_none() && !by_char {
m = parse_textual_month(&entry, &filtered_str).map(u16::from);
}
match (y, m, d) {
(Some(y), Some(m), Some(d)) => Month::try_from(m as u8)
.ok()
.and_then(|m| Date::from_calendar_date(y.into(), m, d as u8).ok())
.map(ParsedDate::Absolute),
_ => None,
}
} else {
None
if nums.is_empty() {
entry
.timeago_nd_tokens
.get(&filtered_str)
.and_then(|t| t.into_timeago())
.or_else(|| TaTokenParser::new(&entry, by_char, true, &filtered_str).next())
.or_else(|| TaTokenParser::new(&entry, by_char, false, &filtered_str).next())
.map(ParsedDate::Relative)
} else {
if nums.len() == 1 {
if let Some(timeago) = TaTokenParser::new(&entry, by_char, false, &filtered_str).next()
{
return Some(ParsedDate::Relative(timeago * nums[0] as u8));
}
}
_ => None,
let mut date_order = entry.date_order;
let with_day = if entry.date_order.len() == nums.len() {
true
} else if entry.date_order.len() - 1 == nums.len() {
false
} else if nums.len() == 1 {
date_order = &[DateCmp::Y];
false
} else {
return None;
};
let mut y: Option<u16> = None;
let mut m: Option<u16> = None;
let mut d: Option<u16> = None;
let mut i = 0;
for dc in date_order.iter() {
match dc {
DateCmp::Y => y = Some(nums[i]),
DateCmp::M => m = Some(nums[i]),
DateCmp::D => {
if with_day {
d = Some(nums[i]);
} else {
continue;
}
}
}
i += 1;
}
if m.is_none() {
m = parse_textual_month(&entry, &filtered_str).map(u16::from);
}
match (y, m, d) {
(Some(y), Some(m), d) => Month::try_from(m as u8)
.ok()
.and_then(|m| Date::from_calendar_date(y.into(), m, d.unwrap_or(1) as u8).ok())
.map(ParsedDate::Absolute),
_ => None,
}
}
}
/// Parse a textual date (e.g. "29 minutes ago" or "Jul 2, 2014") into a Chrono DateTime object.
/// Parse a textual date (e.g. "29 minutes ago" or "Jul 2, 2014") into a OffsetDateTime object.
///
/// Returns None if the date could not be parsed.
pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option<OffsetDateTime> {
parse_textual_date(lang, textual_date).map(OffsetDateTime::from)
}
/// Parse a textual date (e.g. "29 minutes ago" "Jul 2, 2014") into a Date object.
///
/// Returns None if the date could not be parsed.
pub fn parse_textual_date_to_d(
lang: Language,
textual_date: &str,
warnings: &mut Vec<String>,
) -> Option<Date> {
parse_textual_date_or_warn(lang, textual_date, warnings).map(OffsetDateTime::date)
}
pub fn parse_textual_date_or_warn(
lang: Language,
textual_date: &str,
@ -845,6 +894,8 @@ mod tests {
"যোগ দিয়েছেন 24 সেপ, 2013",
Some(ParsedDate::Absolute(date!(2013-9-24)))
)]
#[case(Language::Ja, "2023年7月", Some(ParsedDate::Absolute(date!(2023-07-01))))]
#[case(Language::De, "Juli 2023", Some(ParsedDate::Absolute(date!(2023-07-01))))]
fn t_parse_date(
#[case] lang: Language,
#[case] textual_date: &str,
@ -949,6 +1000,39 @@ mod tests {
}
}
#[test]
fn t_parse_history_date_samples() {
#[derive(Deserialize)]
struct HistoryDates {
this_week: String,
last_week: String,
}
let json_path = path!(*TESTFILES / "dict" / "history_date_samples.json");
let json_file = File::open(json_path).unwrap();
let date_samples: BTreeMap<Language, HistoryDates> =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
for (lang, samples) in date_samples {
assert_eq!(
parse_textual_date(lang, &samples.this_week),
Some(ParsedDate::Relative(TimeAgo {
n: 0,
unit: TimeUnit::LastWeek
})),
"lang: {lang}"
);
assert_eq!(
parse_textual_date(lang, &samples.last_week),
Some(ParsedDate::Relative(TimeAgo {
n: 1,
unit: TimeUnit::LastWeek
})),
"lang: {lang}"
);
}
}
#[test]
fn t_parse_video_duration() {
let json_path = path!(*TESTFILES / "dict" / "video_duration_samples.json");