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

2
.gitignore vendored
View file

@ -4,4 +4,4 @@
*.snap.new
rustypipe_reports
rustypipe_cache.json
rustypipe_cache*.json

View file

@ -28,7 +28,7 @@ testintl:
for YT_LANG in "${LANGUAGES[@]}"; do
echo "---TESTS FOR $YT_LANG ---"
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast ---retries 1 --test-threads 4 --test youtube -E 'not test(/^resolve/)'; then
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast --retries 1 --test-threads 4 --test youtube -E 'not test(/^resolve/)'; then
echo "--- $YT_LANG COMPLETED ---"
else
echo "--- $YT_LANG FAILED ---"

View file

@ -54,6 +54,9 @@ struct Cli {
/// YouTube content country
#[clap(long, global = true)]
country: Option<String>,
/// Use authentication
#[clap(long, global = true)]
auth: bool,
#[clap(long, global = true)]
/// RustyPipe cache file
cache_file: Option<PathBuf>,
@ -653,6 +656,9 @@ async fn run() -> anyhow::Result<()> {
if let Some(country) = cli.country {
rp = rp.country(Country::from_str(&country.to_ascii_uppercase()).expect("invalid country"));
}
if cli.auth {
rp = rp.authenticated();
}
let rp = rp.build()?;
match cli.command {

View file

@ -0,0 +1,69 @@
use std::{collections::BTreeMap, fs::File, io::BufReader};
use path_macro::path;
use rustypipe::{
client::RustyPipe,
param::{Language, LANGUAGES},
};
use serde::{Deserialize, Serialize};
use crate::util::{self, DICT_DIR};
type CollectedDates = BTreeMap<Language, HistoryDates>;
#[derive(Debug, Serialize, Deserialize)]
struct HistoryDates {
this_week: String,
last_week: String,
}
pub async fn collect_dates() {
let json_path = path!(*DICT_DIR / "history_date_samples.json");
let rp = RustyPipe::builder()
.storage_dir("/home/thetadev/Documents/Programmieren/Rust/rustypipe")
.build()
.unwrap();
let mut res: CollectedDates = BTreeMap::new();
for lang in LANGUAGES {
println!("{lang}");
let history = rp.query().lang(lang).music_history().await.unwrap();
if history.items.len() < 3 {
panic!("{lang} empty history")
}
// The indexes have to be adapted before running
let d = HistoryDates {
this_week: history.items[0].playback_date_txt.clone().unwrap(),
last_week: history.items[18].playback_date_txt.clone().unwrap(),
};
res.insert(lang, d);
}
let file = File::create(json_path).unwrap();
serde_json::to_writer_pretty(file, &res).unwrap();
}
pub fn write_samples_to_dict() {
let json_path = path!(*DICT_DIR / "history_date_samples.json");
let json_file = File::open(json_path).unwrap();
let collected_dates: CollectedDates =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let mut dict = util::read_dict();
let langs = dict.keys().copied().collect::<Vec<_>>();
for lang in langs {
let dict_entry = dict.entry(lang).or_default();
let cd = &collected_dates[&lang];
dict_entry
.timeago_nd_tokens
.insert(util::filter_datestr(&cd.this_week), "0Wl".to_owned());
dict_entry
.timeago_nd_tokens
.insert(util::filter_datestr(&cd.last_week), "1Wl".to_owned());
}
util::write_dict(dict);
}

View file

@ -10,7 +10,7 @@ use crate::{
};
fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w?)$").unwrap());
static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w*)$").unwrap());
match TU_PATTERN.captures(tu) {
Some(cap) => (
cap.get(1).unwrap().as_str().parse().unwrap_or(1),
@ -22,6 +22,8 @@ fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
"W" => Some(TimeUnit::Week),
"M" => Some(TimeUnit::Month),
"Y" => Some(TimeUnit::Year),
"Wl" => Some(TimeUnit::LastWeek),
"Wd" => Some(TimeUnit::LastWeekday),
"" => None,
_ => panic!("invalid time unit: {tu}"),
},

View file

@ -3,6 +3,7 @@
mod abtest;
mod collect_album_types;
mod collect_chan_prefixes;
mod collect_history_dates;
mod collect_large_numbers;
mod collect_playlist_dates;
mod collect_video_dates;
@ -30,8 +31,10 @@ enum Commands {
CollectAlbumTypes,
CollectVideoDurations,
CollectVideoDates,
CollectHistoryDates,
CollectChanPrefixes,
ParsePlaylistDates,
ParseHistoryDates,
ParseLargeNumbers,
ParseAlbumTypes,
ParseVideoDurations,
@ -68,10 +71,14 @@ async fn main() {
Commands::CollectVideoDates => {
collect_video_dates::collect_video_dates(cli.concurrency).await;
}
Commands::CollectHistoryDates => {
collect_history_dates::collect_dates().await;
}
Commands::CollectChanPrefixes => {
collect_chan_prefixes::collect_chan_prefixes().await;
}
Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(),
Commands::ParseHistoryDates => collect_history_dates::write_samples_to_dict(),
Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(),
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),

View file

@ -88,6 +88,8 @@ pub enum TimeUnit {
Week,
Month,
Year,
LastWeek,
LastWeekday,
}
impl TimeUnit {
@ -100,6 +102,8 @@ impl TimeUnit {
TimeUnit::Week => "W",
TimeUnit::Month => "M",
TimeUnit::Year => "Y",
TimeUnit::LastWeek => "Wl",
TimeUnit::LastWeekday => "Wd",
}
}
}

View file

@ -77,7 +77,7 @@ pub fn filter_datestr(string: &str) -> String {
.to_lowercase()
.chars()
.filter_map(|c| {
if c == '\u{200b}' || c.is_ascii_digit() {
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
None
} else if c == '-' {
Some(' ')

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");

View file

@ -0,0 +1,87 @@
const fs = require("fs");
const DICT_PATH = "../dictionary.json";
function translateLang(lang) {
switch (lang) {
case "iw": // Hebrew
return "he";
case "zh-CN": // Simplified Chinese
return "zh-Hans";
case "zh-HK":
return "zh-Hant-HK";
case "zh-TW":
return "zh-Hant";
default:
return lang;
}
}
function collectMonthNames(lang, by_char, monthNames, weekdayNames) {
const cldrLang = translateLang(lang);
const dates = require(`cldr-dates-modern/main/${cldrLang}/ca-gregorian.json`);
const dateFields = dates.main[cldrLang].dates.calendars.gregorian;
const months = dateFields.months["stand-alone"].wide;
for (const [n, name] of Object.entries(months)) {
let name2 = name.toLowerCase();
if (name2.includes(n)) {
// Some languages dont have named months
console.log(`${lang}: month name '${name2}' includes number; skipped`);
continue;
}
if (lang === "mn") {
name2 = name2.replace(" сар", "").replace("арван ", "");
}
if (/\s/g.test(name2)) {
throw new Error(`${lang}: month name '${name2}' contains whitespace`);
}
monthNames[name2.toLowerCase()] = parseInt(n);
}
const weekdays = dateFields.days["stand-alone"].wide;
for (const [id, name] of Object.entries(weekdays)) {
let name2 = name.toLowerCase();
if (by_char) {
name2 = name2.replace("曜日", "").replace("星期", "");
if (name2.length != 1) {
throw new Error(`${lang}: single-char name '${name2}' has invalid length`);
}
} else {
if (lang === "iw") {
name2 = name2.replace("יום ", "");
} else if (lang === "sq") {
name2 = name2.replace("e ", "");
}
if (/\s/g.test(name2)) {
// throw new Error(`${lang}: name '${name2}' contains whitespace`);
console.log(`${lang}: weekday name '${name2}' contains whitespace`);
}
}
const ids = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 };
const n = ids[id];
weekdayNames[name2] = `${n}Wd`;
}
}
const dict = JSON.parse(fs.readFileSync(DICT_PATH));
for (const [mainLang, entry] of Object.entries(dict)) {
const langs = [mainLang, ...entry["equivalent"]];
let monthNames = {};
let weekdayNames = {};
for (lang of langs) {
collectMonthNames(lang, entry["by_char"], monthNames, weekdayNames);
}
dict[mainLang]["months"] = { ...dict[mainLang]["months"], ...monthNames };
dict[mainLang]["timeago_nd_tokens"] = {
...dict[mainLang]["timeago_nd_tokens"],
...weekdayNames,
};
}
fs.writeFileSync(DICT_PATH, JSON.stringify(dict, null, 2));

View file

@ -6,7 +6,7 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"cldr-dates-modern": "^43.0.0",
"cldr-numbers-modern": "^43.0.0"
"cldr-dates-modern": "^45.0.0",
"cldr-numbers-modern": "^45.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,334 @@
{
"af": {
"this_week": "Vandeesweek",
"last_week": "Verlede week"
},
"am": {
"this_week": "በዚህ ሳምንት",
"last_week": "ያለፈው ሳምንት"
},
"ar": {
"this_week": "هذا الأسبوع",
"last_week": "الأسبوع الماضي"
},
"as": {
"this_week": "এই সপ্তাহৰ",
"last_week": "যোৱা সপ্তাহৰ"
},
"az": {
"this_week": "Bu həftə",
"last_week": "Ötən həftə"
},
"be": {
"this_week": "На гэтым тыдні",
"last_week": "На мінулым тыдні"
},
"bg": {
"this_week": "Тази седмица",
"last_week": "Последната седмица"
},
"bn": {
"this_week": "এই সপ্তাহে",
"last_week": "গত সপ্তাহ"
},
"bs": {
"this_week": "Ova sedmica",
"last_week": "Prošla sedmica"
},
"ca": {
"this_week": "Aquesta setmana",
"last_week": "La setmana passada"
},
"cs": {
"this_week": "Tento týden",
"last_week": "Minulý týden"
},
"da": {
"this_week": "Denne uge",
"last_week": "Sidste uge"
},
"de": {
"this_week": "Diese Woche",
"last_week": "Letzte Woche"
},
"el": {
"this_week": "Αυτήν την εβδομάδα",
"last_week": "Τελευταία εβδομάδα"
},
"en": {
"this_week": "This week",
"last_week": "Last week"
},
"en-GB": {
"this_week": "This week",
"last_week": "Last week"
},
"en-IN": {
"this_week": "This week",
"last_week": "Last week"
},
"es": {
"this_week": "Esta semana",
"last_week": "La semana pasada"
},
"es-419": {
"this_week": "Esta semana",
"last_week": "La semana pasada"
},
"es-US": {
"this_week": "Esta semana",
"last_week": "La semana pasada"
},
"et": {
"this_week": "Sellel nädalal",
"last_week": "Eelmisel nädalal"
},
"eu": {
"this_week": "Aste hau",
"last_week": "Joan den astea"
},
"fa": {
"this_week": "این هفته",
"last_week": "هفته قبل"
},
"fi": {
"this_week": "Tällä viikolla",
"last_week": "Viime viikolla"
},
"fil": {
"this_week": "Ngayong linggo",
"last_week": "Nakaraang linggo"
},
"fr": {
"this_week": "Cette semaine",
"last_week": "La semaine dernière"
},
"fr-CA": {
"this_week": "Cette semaine",
"last_week": "La semaine dernière"
},
"gl": {
"this_week": "Esta semana",
"last_week": "A semana pasada"
},
"gu": {
"this_week": "આ અઠવાડિયે",
"last_week": "છેલ્લું અઠવાડિયું"
},
"hi": {
"this_week": "इस हफ़्ते",
"last_week": "पिछले हफ़्ते"
},
"hr": {
"this_week": "Ovaj tjedan",
"last_week": "Prošli tjedan"
},
"hu": {
"this_week": "Ezen a héten",
"last_week": "Múlt héten"
},
"hy": {
"this_week": "Այս շաբաթ",
"last_week": "Անցյալ շաբաթ"
},
"id": {
"this_week": "Minggu ini",
"last_week": "Minggu lalu"
},
"is": {
"this_week": "Í vikunni",
"last_week": "Í síðustu viku"
},
"it": {
"this_week": "Questa settimana",
"last_week": "Ultima settimana"
},
"iw": {
"this_week": "השבוע",
"last_week": "בשבוע שעבר"
},
"ja": {
"this_week": "今週",
"last_week": "先週"
},
"ka": {
"this_week": "ამ კვირაში",
"last_week": "გასულ კვირაში"
},
"kk": {
"this_week": "Осы аптада",
"last_week": "Өткен аптада"
},
"km": {
"this_week": "សប្ដាហ៍​នេះ",
"last_week": "សប្ដាហ៍​មុន"
},
"kn": {
"this_week": "ಈ ವಾರ",
"last_week": "ಕಳೆದ ವಾರ"
},
"ko": {
"this_week": "이번 주",
"last_week": "지난주"
},
"ky": {
"this_week": "Ушул аптадагы",
"last_week": "Өткөн аптадагы"
},
"lo": {
"this_week": "ອາທິດນີ້",
"last_week": "ອາທິດຜ່ານ​ມາ"
},
"lt": {
"this_week": "Šią savaitę",
"last_week": "Praėjusią savaitę"
},
"lv": {
"this_week": "Šajā nedēļā",
"last_week": "Iepriekšējā nedēļā"
},
"mk": {
"this_week": "Оваа седмица",
"last_week": "Минатата недела"
},
"ml": {
"this_week": "ഈ ആഴ്‌ച",
"last_week": "കഴിഞ്ഞ ആഴ്ച"
},
"mn": {
"this_week": "Энэ долоо хоног",
"last_week": "Өнгөрсөн долоо хоног"
},
"mr": {
"this_week": "या आठवड्यात",
"last_week": "मागील आठवड्यात"
},
"ms": {
"this_week": "Minggu ini",
"last_week": "Minggu lepas"
},
"my": {
"this_week": "ယခုအပတ်",
"last_week": "ယခင်အပတ်"
},
"ne": {
"this_week": "यस हप्ता",
"last_week": "पछिल्लो हप्ता"
},
"nl": {
"this_week": "Deze week",
"last_week": "Afgelopen week"
},
"no": {
"this_week": "Denne uken",
"last_week": "Forrige uke"
},
"or": {
"this_week": "ଏହି ସପ୍ତାହ",
"last_week": "ଗତ ସପ୍ତାହ"
},
"pa": {
"this_week": "ਇਸ ਹਫ਼ਤੇ",
"last_week": "ਪਿਛਲੇ ਹਫ਼ਤੇ"
},
"pl": {
"this_week": "W tym tygodniu",
"last_week": "W zeszłym tygodniu"
},
"pt": {
"this_week": "Esta semana",
"last_week": "Semana passada"
},
"pt-PT": {
"this_week": "Esta semana",
"last_week": "A semana passada"
},
"ro": {
"this_week": "Săptămâna aceasta",
"last_week": "Săptămâna trecută"
},
"ru": {
"this_week": "На этой неделе",
"last_week": "На прошлой неделе"
},
"si": {
"this_week": "මෙම සතිය",
"last_week": "පසුගිය සතිය"
},
"sk": {
"this_week": "Tento týždeň",
"last_week": "Minulý týždeň"
},
"sl": {
"this_week": "Ta teden",
"last_week": "Prejšnji teden"
},
"sq": {
"this_week": "Këtë javë",
"last_week": "Javën e kaluar"
},
"sr": {
"this_week": "Ове недеље",
"last_week": "Прошле недеље"
},
"sr-Latn": {
"this_week": "Ove nedelje",
"last_week": "Prošle nedelje"
},
"sv": {
"this_week": "Den här veckan",
"last_week": "Förra veckan"
},
"sw": {
"this_week": "Wiki hii",
"last_week": "Wiki iliyopita"
},
"ta": {
"this_week": "இந்த வாரம்",
"last_week": "கடந்த வாரம்"
},
"te": {
"this_week": "ఈ వారం",
"last_week": "గత వారం"
},
"th": {
"this_week": "สัปดาห์นี้",
"last_week": "สัปดาห์ที่แล้ว"
},
"tr": {
"this_week": "Bu hafta",
"last_week": "Geçen hafta"
},
"uk": {
"this_week": "Цього тижня",
"last_week": "Минулого тижня"
},
"ur": {
"this_week": "اس ہفتے",
"last_week": "گزشتہ ہفتہ"
},
"uz": {
"this_week": "Shu haftada",
"last_week": "Otgan hafta"
},
"vi": {
"this_week": "Tuần này",
"last_week": "Tuần trước"
},
"zh-CN": {
"this_week": "本周",
"last_week": "上周"
},
"zh-HK": {
"this_week": "本星期",
"last_week": "上星期"
},
"zh-TW": {
"this_week": "本週",
"last_week": "上週"
},
"zu": {
"this_week": "Leli viki",
"last_week": "Iviki eledlule"
}
}

View file

@ -5,7 +5,7 @@ use std::fmt::Display;
use std::str::FromStr;
use rstest::{fixture, rstest};
use rustypipe::model::TrackType;
use rustypipe::model::{HistoryItem, TrackItem, TrackType, VideoItem};
use rustypipe::param::{AlbumOrder, LANGUAGES};
use time::{macros::date, OffsetDateTime};
@ -2751,7 +2751,7 @@ async fn history(rp: RustyPipe, cookie_auth_enabled: bool) {
}
let videos = rp.query().history().await.unwrap();
assert_next_items(videos, rp.query(), 100).await;
assert_next_history(videos, rp.query(), 100).await;
}
#[rstest]
@ -2762,7 +2762,7 @@ async fn history_search(rp: RustyPipe, cookie_auth_enabled: bool) {
}
let videos = rp.query().history_search("a").await.unwrap();
assert_next_items(videos, rp.query(), 5).await;
assert_next_history(videos, rp.query(), 5).await;
}
#[rstest]
@ -2817,7 +2817,7 @@ async fn music_history(rp: RustyPipe, cookie_auth_enabled: bool) {
}
let tracks = rp.query().music_history().await.unwrap();
assert_next_items(tracks, rp.query(), 150).await;
assert_next_music_history(tracks, rp.query(), 150).await;
}
#[rstest]
@ -2998,6 +2998,30 @@ async fn assert_next_items<T: FromYtItem, Q: AsRef<RustyPipeQuery>>(
assert_gte(p.items.len(), n_items, "items");
}
/// Assert that the history paginator produces at least n items
async fn assert_next_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<VideoItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
/// Assert that the music history paginator produces at least n items
async fn assert_next_music_history<Q: AsRef<RustyPipeQuery>>(
paginator: Paginator<HistoryItem<TrackItem>>,
query: Q,
n_items: usize,
) {
let mut p = paginator;
let query = query.as_ref();
p.extend_limit(query, n_items).await.unwrap();
assert_gte(p.items.len(), n_items, "items");
}
#[track_caller]
fn assert_frameset(frameset: &Frameset) {
assert_gte(frameset.frame_height, 20, "frame height");