feat: add history item dates, extend timeago parser
This commit is contained in:
parent
2677a5bdae
commit
b57bd550df
28 changed files with 6507 additions and 2160 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,4 +4,4 @@
|
||||||
*.snap.new
|
*.snap.new
|
||||||
|
|
||||||
rustypipe_reports
|
rustypipe_reports
|
||||||
rustypipe_cache.json
|
rustypipe_cache*.json
|
||||||
|
|
|
||||||
2
Justfile
2
Justfile
|
|
@ -28,7 +28,7 @@ testintl:
|
||||||
for YT_LANG in "${LANGUAGES[@]}"; do
|
for YT_LANG in "${LANGUAGES[@]}"; do
|
||||||
echo "---TESTS FOR $YT_LANG ---"
|
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 ---"
|
echo "--- $YT_LANG COMPLETED ---"
|
||||||
else
|
else
|
||||||
echo "--- $YT_LANG FAILED ---"
|
echo "--- $YT_LANG FAILED ---"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ struct Cli {
|
||||||
/// YouTube content country
|
/// YouTube content country
|
||||||
#[clap(long, global = true)]
|
#[clap(long, global = true)]
|
||||||
country: Option<String>,
|
country: Option<String>,
|
||||||
|
/// Use authentication
|
||||||
|
#[clap(long, global = true)]
|
||||||
|
auth: bool,
|
||||||
#[clap(long, global = true)]
|
#[clap(long, global = true)]
|
||||||
/// RustyPipe cache file
|
/// RustyPipe cache file
|
||||||
cache_file: Option<PathBuf>,
|
cache_file: Option<PathBuf>,
|
||||||
|
|
@ -653,6 +656,9 @@ async fn run() -> anyhow::Result<()> {
|
||||||
if let Some(country) = cli.country {
|
if let Some(country) = cli.country {
|
||||||
rp = rp.country(Country::from_str(&country.to_ascii_uppercase()).expect("invalid country"));
|
rp = rp.country(Country::from_str(&country.to_ascii_uppercase()).expect("invalid country"));
|
||||||
}
|
}
|
||||||
|
if cli.auth {
|
||||||
|
rp = rp.authenticated();
|
||||||
|
}
|
||||||
let rp = rp.build()?;
|
let rp = rp.build()?;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
|
|
|
||||||
69
codegen/src/collect_history_dates.rs
Normal file
69
codegen/src/collect_history_dates.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
|
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) {
|
match TU_PATTERN.captures(tu) {
|
||||||
Some(cap) => (
|
Some(cap) => (
|
||||||
cap.get(1).unwrap().as_str().parse().unwrap_or(1),
|
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),
|
"W" => Some(TimeUnit::Week),
|
||||||
"M" => Some(TimeUnit::Month),
|
"M" => Some(TimeUnit::Month),
|
||||||
"Y" => Some(TimeUnit::Year),
|
"Y" => Some(TimeUnit::Year),
|
||||||
|
"Wl" => Some(TimeUnit::LastWeek),
|
||||||
|
"Wd" => Some(TimeUnit::LastWeekday),
|
||||||
"" => None,
|
"" => None,
|
||||||
_ => panic!("invalid time unit: {tu}"),
|
_ => panic!("invalid time unit: {tu}"),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
mod abtest;
|
mod abtest;
|
||||||
mod collect_album_types;
|
mod collect_album_types;
|
||||||
mod collect_chan_prefixes;
|
mod collect_chan_prefixes;
|
||||||
|
mod collect_history_dates;
|
||||||
mod collect_large_numbers;
|
mod collect_large_numbers;
|
||||||
mod collect_playlist_dates;
|
mod collect_playlist_dates;
|
||||||
mod collect_video_dates;
|
mod collect_video_dates;
|
||||||
|
|
@ -30,8 +31,10 @@ enum Commands {
|
||||||
CollectAlbumTypes,
|
CollectAlbumTypes,
|
||||||
CollectVideoDurations,
|
CollectVideoDurations,
|
||||||
CollectVideoDates,
|
CollectVideoDates,
|
||||||
|
CollectHistoryDates,
|
||||||
CollectChanPrefixes,
|
CollectChanPrefixes,
|
||||||
ParsePlaylistDates,
|
ParsePlaylistDates,
|
||||||
|
ParseHistoryDates,
|
||||||
ParseLargeNumbers,
|
ParseLargeNumbers,
|
||||||
ParseAlbumTypes,
|
ParseAlbumTypes,
|
||||||
ParseVideoDurations,
|
ParseVideoDurations,
|
||||||
|
|
@ -68,10 +71,14 @@ async fn main() {
|
||||||
Commands::CollectVideoDates => {
|
Commands::CollectVideoDates => {
|
||||||
collect_video_dates::collect_video_dates(cli.concurrency).await;
|
collect_video_dates::collect_video_dates(cli.concurrency).await;
|
||||||
}
|
}
|
||||||
|
Commands::CollectHistoryDates => {
|
||||||
|
collect_history_dates::collect_dates().await;
|
||||||
|
}
|
||||||
Commands::CollectChanPrefixes => {
|
Commands::CollectChanPrefixes => {
|
||||||
collect_chan_prefixes::collect_chan_prefixes().await;
|
collect_chan_prefixes::collect_chan_prefixes().await;
|
||||||
}
|
}
|
||||||
Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(),
|
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::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(),
|
||||||
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
|
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
|
||||||
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),
|
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ pub enum TimeUnit {
|
||||||
Week,
|
Week,
|
||||||
Month,
|
Month,
|
||||||
Year,
|
Year,
|
||||||
|
LastWeek,
|
||||||
|
LastWeekday,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TimeUnit {
|
impl TimeUnit {
|
||||||
|
|
@ -100,6 +102,8 @@ impl TimeUnit {
|
||||||
TimeUnit::Week => "W",
|
TimeUnit::Week => "W",
|
||||||
TimeUnit::Month => "M",
|
TimeUnit::Month => "M",
|
||||||
TimeUnit::Year => "Y",
|
TimeUnit::Year => "Y",
|
||||||
|
TimeUnit::LastWeek => "Wl",
|
||||||
|
TimeUnit::LastWeekday => "Wd",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ pub fn filter_datestr(string: &str) -> String {
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.chars()
|
.chars()
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
if c == '\u{200b}' || c.is_ascii_digit() {
|
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
|
||||||
None
|
None
|
||||||
} else if c == '-' {
|
} else if c == '-' {
|
||||||
Some(' ')
|
Some(' ')
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{
|
model::{
|
||||||
paginator::{ContinuationEndpoint, Paginator},
|
paginator::{ContinuationEndpoint, Paginator},
|
||||||
ChannelItem, VideoItem,
|
ChannelItem, HistoryItem, VideoItem,
|
||||||
},
|
},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use self::response::YouTubeListMapper;
|
||||||
|
|
||||||
|
use super::{MapRespOptions, QContinuation};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QHistorySearch<'a> {
|
struct QHistorySearch<'a> {
|
||||||
|
|
@ -24,7 +28,7 @@ impl RustyPipeQuery {
|
||||||
///
|
///
|
||||||
/// Requires authentication cookies.
|
/// Requires authentication cookies.
|
||||||
#[tracing::instrument(skip(self), level = "error")]
|
#[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 {
|
let request_body = QBrowse {
|
||||||
browse_id: "FEhistory",
|
browse_id: "FEhistory",
|
||||||
};
|
};
|
||||||
|
|
@ -41,6 +45,34 @@ impl RustyPipeQuery {
|
||||||
.await
|
.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
|
/// Search the YouTube playback history of the current user
|
||||||
///
|
///
|
||||||
/// Requires authentication cookies.
|
/// Requires authentication cookies.
|
||||||
|
|
@ -48,7 +80,7 @@ impl RustyPipeQuery {
|
||||||
pub async fn history_search<S: AsRef<str> + Debug>(
|
pub async fn history_search<S: AsRef<str> + Debug>(
|
||||||
&self,
|
&self,
|
||||||
query: S,
|
query: S,
|
||||||
) -> Result<Paginator<VideoItem>, Error> {
|
) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
|
||||||
let query = query.as_ref();
|
let query = query.as_ref();
|
||||||
let request_body = QHistorySearch {
|
let request_body = QHistorySearch {
|
||||||
browse_id: "FEhistory",
|
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 {
|
impl MapResponse<Paginator<VideoItem>> for response::History {
|
||||||
fn map_response(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
|
|
@ -131,7 +222,7 @@ impl MapResponse<Paginator<VideoItem>> for response::History {
|
||||||
None,
|
None,
|
||||||
mapper.items,
|
mapper.items,
|
||||||
mapper.ctoken,
|
mapper.ctoken,
|
||||||
None,
|
ctx.visitor_data.map(str::to_owned),
|
||||||
crate::model::paginator::ContinuationEndpoint::Browse,
|
crate::model::paginator::ContinuationEndpoint::Browse,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
|
@ -145,29 +236,47 @@ mod tests {
|
||||||
use std::{fs::File, io::BufReader};
|
use std::{fs::File, io::BufReader};
|
||||||
|
|
||||||
use path_macro::path;
|
use path_macro::path;
|
||||||
use rstest::rstest;
|
|
||||||
|
|
||||||
use crate::util::tests::TESTFILES;
|
use crate::util::tests::TESTFILES;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[rstest]
|
#[test]
|
||||||
#[case::history("history")]
|
fn map_history() {
|
||||||
#[case::subscription_feed("subscription_feed")]
|
let json_path = path!(*TESTFILES / "history" / "history.json");
|
||||||
fn map_history(#[case] name: &str) {
|
|
||||||
let json_path = path!(*TESTFILES / "history" / format!("{name}.json"));
|
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
let history: response::History =
|
let history: response::History =
|
||||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
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!(
|
assert!(
|
||||||
map_res.warnings.is_empty(),
|
map_res.warnings.is_empty(),
|
||||||
"deserialization/mapping warnings: {:?}",
|
"deserialization/mapping warnings: {:?}",
|
||||||
map_res.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]",
|
".items[].publish_date" => "[date]",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,16 @@ impl RustyPipeBuilder {
|
||||||
self
|
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
|
/// Disable authentication for all requests
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn unauthenticated(mut self) -> Self {
|
pub fn unauthenticated(mut self) -> Self {
|
||||||
|
|
|
||||||
|
|
@ -113,12 +113,12 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
|
||||||
});
|
});
|
||||||
|
|
||||||
let mapped_top = mapper_top.conv_items::<TrackItem>();
|
let mapped_top = mapper_top.conv_items::<TrackItem>();
|
||||||
let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
let mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
||||||
let mut mapped_other = mapper_other.group_items();
|
let mapped_other = mapper_other.group_items();
|
||||||
|
|
||||||
let mut warnings = mapped_top.warnings;
|
let mut warnings = mapped_top.warnings;
|
||||||
warnings.append(&mut mapped_trending.warnings);
|
warnings.extend(mapped_trending.warnings);
|
||||||
warnings.append(&mut mapped_other.warnings);
|
warnings.extend(mapped_other.warnings);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: MusicCharts {
|
c: MusicCharts {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
client::{
|
client::{
|
||||||
response::{self, music_item::MusicListMapper},
|
response::{self, music_item::MusicListMapper},
|
||||||
|
|
@ -6,19 +8,19 @@ use crate::{
|
||||||
error::{Error, ExtractionError},
|
error::{Error, ExtractionError},
|
||||||
model::{
|
model::{
|
||||||
paginator::{ContinuationEndpoint, Paginator},
|
paginator::{ContinuationEndpoint, Paginator},
|
||||||
AlbumItem, ArtistItem, MusicPlaylistItem, TrackItem,
|
AlbumItem, ArtistItem, HistoryItem, MusicPlaylistItem, TrackItem,
|
||||||
},
|
},
|
||||||
serializer::MapResult,
|
serializer::MapResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::MapRespCtx;
|
use super::{MapRespCtx, MapRespOptions, QContinuation};
|
||||||
|
|
||||||
impl RustyPipeQuery {
|
impl RustyPipeQuery {
|
||||||
/// Get a list of tracks from YouTube Music which the current user recently played
|
/// Get a list of tracks from YouTube Music which the current user recently played
|
||||||
///
|
///
|
||||||
/// Requires authentication cookies.
|
/// Requires authentication cookies.
|
||||||
#[tracing::instrument(skip(self), level = "error")]
|
#[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 {
|
let request_body = QBrowseParams {
|
||||||
browse_id: "FEmusic_history",
|
browse_id: "FEmusic_history",
|
||||||
params: "oggECgIIAQ%3D%3D",
|
params: "oggECgIIAQ%3D%3D",
|
||||||
|
|
@ -36,6 +38,34 @@ impl RustyPipeQuery {
|
||||||
.await
|
.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
|
/// Get a list of YouTube Music artists which the current user subscribed to
|
||||||
///
|
///
|
||||||
/// Requires authentication cookies.
|
/// 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(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
ctx: &MapRespCtx<'_>,
|
ctx: &MapRespCtx<'_>,
|
||||||
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
|
||||||
let contents = match self.contents {
|
let contents = match self.contents {
|
||||||
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
|
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
|
||||||
c.contents
|
c.contents
|
||||||
|
|
@ -120,7 +150,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
||||||
} => secondary_contents.section_list_renderer,
|
} => secondary_contents.section_list_renderer,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
let mut map_res = MapResult::default();
|
||||||
|
|
||||||
for shelf in contents.contents {
|
for shelf in contents.contents {
|
||||||
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
|
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
|
||||||
|
|
@ -128,11 +158,11 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||||
mapper.map_response(shelf.contents);
|
mapper.map_response(shelf.contents);
|
||||||
|
mapper.conv_history_items(shelf.title, &mut map_res);
|
||||||
}
|
}
|
||||||
|
|
||||||
let map_res = mapper.conv_items();
|
|
||||||
|
|
||||||
let ctoken = contents
|
let ctoken = contents
|
||||||
.continuations
|
.continuations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -144,7 +174,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
||||||
None,
|
None,
|
||||||
map_res.c,
|
map_res.c,
|
||||||
ctoken,
|
ctoken,
|
||||||
None,
|
ctx.visitor_data.map(str::to_owned),
|
||||||
ContinuationEndpoint::MusicBrowse,
|
ContinuationEndpoint::MusicBrowse,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
|
@ -177,6 +207,8 @@ mod tests {
|
||||||
"deserialization/mapping warnings: {:?}",
|
"deserialization/mapping warnings: {:?}",
|
||||||
map_res.warnings
|
map_res.warnings
|
||||||
);
|
);
|
||||||
insta::assert_ron_snapshot!(map_res.c);
|
insta::assert_ron_snapshot!(map_res.c, {
|
||||||
|
".items[].playback_date" => "[date]",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@ use crate::model::{
|
||||||
traits::FromYtItem,
|
traits::FromYtItem,
|
||||||
Comment, MusicItem, YouTubeItem,
|
Comment, MusicItem, YouTubeItem,
|
||||||
};
|
};
|
||||||
|
use crate::model::{HistoryItem, TrackItem, VideoItem};
|
||||||
use crate::serializer::MapResult;
|
use crate::serializer::MapResult;
|
||||||
|
|
||||||
|
use self::response::YouTubeListItem;
|
||||||
|
|
||||||
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
||||||
use super::{
|
use super::{
|
||||||
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
|
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 {
|
impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
|
||||||
fn map_response(
|
fn map_response(
|
||||||
self,
|
self,
|
||||||
ctx: &MapRespCtx<'_>,
|
ctx: &MapRespCtx<'_>,
|
||||||
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
|
||||||
let items = self
|
let estimated_results = self.estimated_results;
|
||||||
.on_response_received_actions
|
let items = continuation_items(self);
|
||||||
.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 mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
|
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
|
||||||
mapper.map_response(items);
|
mapper.map_response(items);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: Paginator::new_ext(
|
c: Paginator::new_ext(
|
||||||
self.estimated_results,
|
estimated_results,
|
||||||
mapper.items,
|
mapper.items,
|
||||||
mapper.ctoken,
|
mapper.ctoken,
|
||||||
None,
|
ctx.visitor_data.map(str::to_owned),
|
||||||
ContinuationEndpoint::Browse,
|
ContinuationEndpoint::Browse,
|
||||||
ctx.authenticated,
|
ctx.authenticated,
|
||||||
),
|
),
|
||||||
|
|
@ -201,7 +210,99 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||||
None,
|
None,
|
||||||
map_res.c,
|
map_res.c,
|
||||||
ctoken,
|
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,
|
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,
|
ContinuationEndpoint::MusicBrowse,
|
||||||
ctx.authenticated,
|
ctx.authenticated,
|
||||||
),
|
),
|
||||||
|
|
@ -213,11 +314,6 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
||||||
impl<T: FromYtItem> Paginator<T> {
|
impl<T: FromYtItem> Paginator<T> {
|
||||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
/// 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> {
|
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 {
|
Ok(match &self.ctoken {
|
||||||
Some(ctoken) => {
|
Some(ctoken) => {
|
||||||
let q = if self.authenticated {
|
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 {
|
macro_rules! paginator {
|
||||||
($entity_type:ty) => {
|
($entity_type:ty) => {
|
||||||
impl Paginator<$entity_type> {
|
impl Paginator<$entity_type> {
|
||||||
|
|
@ -400,6 +526,8 @@ macro_rules! paginator {
|
||||||
}
|
}
|
||||||
|
|
||||||
paginator!(Comment);
|
paginator!(Comment);
|
||||||
|
paginator!(HistoryItem<VideoItem>);
|
||||||
|
paginator!(HistoryItem<TrackItem>);
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,14 @@ pub(crate) struct TextBox {
|
||||||
pub text: String,
|
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]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkip
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
|
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
|
||||||
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
HistoryItem, MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
||||||
},
|
},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
|
|
@ -18,7 +18,7 @@ use super::{
|
||||||
url_endpoint::{
|
url_endpoint::{
|
||||||
BrowseEndpointWrap, MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
|
BrowseEndpointWrap, MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
|
||||||
},
|
},
|
||||||
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
|
ContentsRenderer, MusicContinuationData, SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
|
@ -39,6 +39,8 @@ pub(crate) enum ItemSection {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct MusicShelf {
|
pub(crate) struct MusicShelf {
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub title: Option<String>,
|
||||||
/// Playlist ID (only for playlists)
|
/// Playlist ID (only for playlists)
|
||||||
pub playlist_id: Option<String>,
|
pub playlist_id: Option<String>,
|
||||||
pub contents: MapResult<Vec<MusicResponseItem>>,
|
pub contents: MapResult<Vec<MusicResponseItem>>,
|
||||||
|
|
@ -396,15 +398,7 @@ pub(crate) struct GridRenderer {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) struct GridHeader {
|
pub(crate) struct GridHeader {
|
||||||
pub grid_header_renderer: GridHeaderRenderer,
|
pub grid_header_renderer: SimpleHeaderRenderer,
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct GridHeaderRenderer {
|
|
||||||
#[serde_as(as = "Text")]
|
|
||||||
pub title: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -419,14 +413,6 @@ pub(crate) struct SimpleHeader {
|
||||||
pub music_header_renderer: SimpleHeaderRenderer,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub(crate) enum TrackBadge {
|
pub(crate) enum TrackBadge {
|
||||||
|
|
@ -1257,6 +1243,26 @@ impl MusicListMapper {
|
||||||
warnings: self.warnings,
|
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
|
/// Map TextComponents containing artist names to a list of artists and a 'Various Artists' flag
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ use serde_with::{
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
|
use super::{
|
||||||
|
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
|
||||||
|
Thumbnails,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
model::{Channel, ChannelItem, ChannelTag, HistoryItem, PlaylistItem, VideoItem, YouTubeItem},
|
||||||
param::Language,
|
param::Language,
|
||||||
serializer::{
|
serializer::{
|
||||||
text::{AttributedText, Text, TextComponent},
|
text::{AttributedText, Text, TextComponent},
|
||||||
|
|
@ -63,6 +66,7 @@ pub(crate) enum YouTubeListItem {
|
||||||
/// GridRenderer: contains videos on channel page
|
/// GridRenderer: contains videos on channel page
|
||||||
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
||||||
ItemSectionRenderer {
|
ItemSectionRenderer {
|
||||||
|
header: Option<ItemSectionHeader>,
|
||||||
#[serde(alias = "items")]
|
#[serde(alias = "items")]
|
||||||
contents: MapResult<Vec<YouTubeListItem>>,
|
contents: MapResult<Vec<YouTubeListItem>>,
|
||||||
},
|
},
|
||||||
|
|
@ -294,6 +298,12 @@ pub(crate) struct YouTubeListRenderer {
|
||||||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
pub contents: MapResult<Vec<YouTubeListItem>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct ItemSectionHeader {
|
||||||
|
pub item_section_header_renderer: SimpleHeaderRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -833,7 +843,7 @@ impl YouTubeListMapper<YouTubeItem> {
|
||||||
YouTubeListItem::RichItemRenderer { content } => {
|
YouTubeListItem::RichItemRenderer { content } => {
|
||||||
self.map_item(*content);
|
self.map_item(*content);
|
||||||
}
|
}
|
||||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||||
self.warnings.append(&mut contents.warnings);
|
self.warnings.append(&mut contents.warnings);
|
||||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||||
}
|
}
|
||||||
|
|
@ -881,7 +891,7 @@ impl YouTubeListMapper<VideoItem> {
|
||||||
YouTubeListItem::RichItemRenderer { content } => {
|
YouTubeListItem::RichItemRenderer { content } => {
|
||||||
self.map_item(*content);
|
self.map_item(*content);
|
||||||
}
|
}
|
||||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||||
self.warnings.append(&mut contents.warnings);
|
self.warnings.append(&mut contents.warnings);
|
||||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||||
}
|
}
|
||||||
|
|
@ -893,6 +903,23 @@ impl YouTubeListMapper<VideoItem> {
|
||||||
self.warnings.append(&mut res.warnings);
|
self.warnings.append(&mut res.warnings);
|
||||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
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> {
|
impl YouTubeListMapper<PlaylistItem> {
|
||||||
|
|
@ -916,7 +943,7 @@ impl YouTubeListMapper<PlaylistItem> {
|
||||||
YouTubeListItem::RichItemRenderer { content } => {
|
YouTubeListItem::RichItemRenderer { content } => {
|
||||||
self.map_item(*content);
|
self.map_item(*content);
|
||||||
}
|
}
|
||||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||||
self.warnings.append(&mut contents.warnings);
|
self.warnings.append(&mut contents.warnings);
|
||||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,240 +5,260 @@ expression: map_res.c
|
||||||
Paginator(
|
Paginator(
|
||||||
count: None,
|
count: None,
|
||||||
items: [
|
items: [
|
||||||
VideoItem(
|
HistoryItem(
|
||||||
id: "mPshy_DWxfo",
|
item: VideoItem(
|
||||||
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
|
id: "mPshy_DWxfo",
|
||||||
duration: Some(6),
|
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
|
||||||
thumbnail: [
|
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: [
|
|
||||||
Thumbnail(
|
Thumbnail(
|
||||||
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
|
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
|
||||||
width: 68,
|
width: 168,
|
||||||
height: 68,
|
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,
|
channel: Some(ChannelTag(
|
||||||
subscriber_count: None,
|
id: "UCM7OXM6t80a3e3tzQDWxwEA",
|
||||||
)),
|
name: "Ari",
|
||||||
publish_date: "[date]",
|
avatar: [
|
||||||
publish_date_txt: None,
|
Thumbnail(
|
||||||
view_count: Some(15),
|
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
|
||||||
is_live: false,
|
width: 68,
|
||||||
is_short: false,
|
height: 68,
|
||||||
is_upcoming: false,
|
),
|
||||||
short_description: None,
|
],
|
||||||
|
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(
|
HistoryItem(
|
||||||
id: "SRWatgS077k",
|
item: VideoItem(
|
||||||
name: "My Time at \"Camp Operetta\"",
|
id: "SRWatgS077k",
|
||||||
duration: Some(578),
|
name: "My Time at \"Camp Operetta\"",
|
||||||
thumbnail: [
|
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: [
|
|
||||||
Thumbnail(
|
Thumbnail(
|
||||||
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
|
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
|
||||||
width: 68,
|
width: 168,
|
||||||
height: 68,
|
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,
|
channel: Some(ChannelTag(
|
||||||
subscriber_count: None,
|
id: "UCGwu0nbY2wSkW8N-cghnLpA",
|
||||||
)),
|
name: "JaidenAnimations",
|
||||||
publish_date: "[date]",
|
avatar: [
|
||||||
publish_date_txt: None,
|
Thumbnail(
|
||||||
view_count: Some(23907328),
|
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
|
||||||
is_live: false,
|
width: 68,
|
||||||
is_short: false,
|
height: 68,
|
||||||
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:..."),
|
],
|
||||||
|
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(
|
HistoryItem(
|
||||||
id: "kTxlkDoqArA",
|
item: VideoItem(
|
||||||
name: "Wie Cartoons Früher gemacht wurden!",
|
id: "kTxlkDoqArA",
|
||||||
duration: Some(283),
|
name: "Wie Cartoons Früher gemacht wurden!",
|
||||||
thumbnail: [
|
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: [
|
|
||||||
Thumbnail(
|
Thumbnail(
|
||||||
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
|
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
|
||||||
width: 68,
|
width: 168,
|
||||||
height: 68,
|
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,
|
channel: Some(ChannelTag(
|
||||||
subscriber_count: None,
|
id: "UCxTHCMaxURhapisCMBv8y0A",
|
||||||
)),
|
name: "Plankton",
|
||||||
publish_date: "[date]",
|
avatar: [
|
||||||
publish_date_txt: None,
|
Thumbnail(
|
||||||
view_count: Some(390010),
|
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
|
||||||
is_live: false,
|
width: 68,
|
||||||
is_short: false,
|
height: 68,
|
||||||
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"),
|
],
|
||||||
|
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(
|
HistoryItem(
|
||||||
id: "oIVSKQ8NMqk",
|
item: VideoItem(
|
||||||
name: "What I learned on highschool swim",
|
id: "oIVSKQ8NMqk",
|
||||||
duration: Some(620),
|
name: "What I learned on highschool swim",
|
||||||
thumbnail: [
|
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: [
|
|
||||||
Thumbnail(
|
Thumbnail(
|
||||||
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
|
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
|
||||||
width: 68,
|
width: 168,
|
||||||
height: 68,
|
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,
|
channel: Some(ChannelTag(
|
||||||
subscriber_count: None,
|
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
|
||||||
)),
|
name: "illymation",
|
||||||
publish_date: "[date]",
|
avatar: [
|
||||||
publish_date_txt: None,
|
Thumbnail(
|
||||||
view_count: Some(6491367),
|
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
|
||||||
is_live: false,
|
width: 68,
|
||||||
is_short: false,
|
height: 68,
|
||||||
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..."),
|
],
|
||||||
|
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(
|
HistoryItem(
|
||||||
id: "X30eFeqrHJo",
|
item: VideoItem(
|
||||||
name: "My Last Week of University!",
|
id: "X30eFeqrHJo",
|
||||||
duration: Some(659),
|
name: "My Last Week of University!",
|
||||||
thumbnail: [
|
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: [
|
|
||||||
Thumbnail(
|
Thumbnail(
|
||||||
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
|
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
|
||||||
width: 68,
|
width: 168,
|
||||||
height: 68,
|
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,
|
channel: Some(ChannelTag(
|
||||||
subscriber_count: None,
|
id: "UC6a8lp6vaCMhUVXPyynhjUA",
|
||||||
)),
|
name: "Ruby Granger",
|
||||||
publish_date: "[date]",
|
avatar: [
|
||||||
publish_date_txt: None,
|
Thumbnail(
|
||||||
view_count: Some(132844),
|
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
|
||||||
is_live: false,
|
width: 68,
|
||||||
is_short: false,
|
height: 68,
|
||||||
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..."),
|
],
|
||||||
|
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"),
|
ctoken: Some("4qmFsgJMEglGRWhpc3RvcnkaKENBSjZHbmx5WVRWb2QyOU9RMmR6U1RSaGNXMTFkMWxSTms4M05WcFKaAhRicm93c2UtZmVlZEZFaGlzdG9yeQ%3D%3D"),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1331,3 +1331,15 @@ pub struct MusicSearchSuggestion {
|
||||||
/// Suggested music items
|
/// Suggested music items
|
||||||
pub items: Vec<MusicItem>,
|
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>,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use time::{Date, Month, OffsetDateTime};
|
use time::{Date, Duration, Month, OffsetDateTime};
|
||||||
|
|
||||||
/// Shift a date by the given number of months.
|
/// Shift a date by the given number of months.
|
||||||
/// Ambiguous month-ends are shifted backwards as necessary.
|
/// 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)
|
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
|
/// Get the current datetime without milli/micro/nanoseconds
|
||||||
pub fn now_sec() -> OffsetDateTime {
|
pub fn now_sec() -> OffsetDateTime {
|
||||||
OffsetDateTime::now_utc()
|
OffsetDateTime::now_utc()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ mod protobuf;
|
||||||
pub mod dictionary;
|
pub mod dictionary;
|
||||||
pub mod timeago;
|
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};
|
pub use protobuf::{string_from_pb, ProtoBuilder};
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ pub enum TimeUnit {
|
||||||
Week,
|
Week,
|
||||||
Month,
|
Month,
|
||||||
Year,
|
Year,
|
||||||
|
LastWeek,
|
||||||
|
LastWeekday,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Value of a parsed TimeAgo token, used in the dictionary
|
/// Value of a parsed TimeAgo token, used in the dictionary
|
||||||
|
|
@ -86,10 +88,17 @@ impl TimeUnit {
|
||||||
TimeUnit::Week => 7 * 24 * 3600,
|
TimeUnit::Week => 7 * 24 * 3600,
|
||||||
TimeUnit::Month => 30 * 24 * 3600,
|
TimeUnit::Month => 30 * 24 * 3600,
|
||||||
TimeUnit::Year => 365 * 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 {
|
impl TimeAgo {
|
||||||
fn secs(self) -> u32 {
|
fn secs(self) -> u32 {
|
||||||
u32::from(self.n) * self.unit.secs()
|
u32::from(self.n) * self.unit.secs()
|
||||||
|
|
@ -119,6 +128,17 @@ impl From<TimeAgo> for OffsetDateTime {
|
||||||
match ta.unit {
|
match ta.unit {
|
||||||
TimeUnit::Month => ts.replace_date(util::shift_months(ts.date(), -i32::from(ta.n))),
|
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::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),
|
_ => ts - Duration::from(ta),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +159,7 @@ fn filter_datestr(string: &str) -> String {
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.chars()
|
.chars()
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
if matches!(c, '\u{200b}' | '.') || c.is_ascii_digit() {
|
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
|
||||||
None
|
None
|
||||||
} else if c == '-' {
|
} else if c == '-' {
|
||||||
Some(' ')
|
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);
|
let nums = util::parse_numeric_vec::<u16>(textual_date);
|
||||||
|
|
||||||
match nums.len() {
|
if nums.is_empty() {
|
||||||
0 => match TaTokenParser::new(&entry, by_char, true, &filtered_str).next() {
|
entry
|
||||||
Some(timeago) => Some(ParsedDate::Relative(timeago)),
|
.timeago_nd_tokens
|
||||||
None => TaTokenParser::new(&entry, by_char, false, &filtered_str)
|
.get(&filtered_str)
|
||||||
.next()
|
.and_then(|t| t.into_timeago())
|
||||||
.map(ParsedDate::Relative),
|
.or_else(|| TaTokenParser::new(&entry, by_char, true, &filtered_str).next())
|
||||||
},
|
.or_else(|| TaTokenParser::new(&entry, by_char, false, &filtered_str).next())
|
||||||
1 => TaTokenParser::new(&entry, by_char, false, &filtered_str)
|
.map(ParsedDate::Relative)
|
||||||
.next()
|
} else {
|
||||||
.map(|timeago| ParsedDate::Relative(timeago * nums[0] as u8)),
|
if nums.len() == 1 {
|
||||||
2..=3 => {
|
if let Some(timeago) = TaTokenParser::new(&entry, by_char, false, &filtered_str).next()
|
||||||
if nums.len() == entry.date_order.len() {
|
{
|
||||||
let mut y: Option<u16> = None;
|
return Some(ParsedDate::Relative(timeago * nums[0] as u8));
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => 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.
|
/// Returns None if the date could not be parsed.
|
||||||
pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option<OffsetDateTime> {
|
pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option<OffsetDateTime> {
|
||||||
parse_textual_date(lang, textual_date).map(OffsetDateTime::from)
|
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(
|
pub fn parse_textual_date_or_warn(
|
||||||
lang: Language,
|
lang: Language,
|
||||||
textual_date: &str,
|
textual_date: &str,
|
||||||
|
|
@ -845,6 +894,8 @@ mod tests {
|
||||||
"যোগ দিয়েছেন 24 সেপ, 2013",
|
"যোগ দিয়েছেন 24 সেপ, 2013",
|
||||||
Some(ParsedDate::Absolute(date!(2013-9-24)))
|
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(
|
fn t_parse_date(
|
||||||
#[case] lang: Language,
|
#[case] lang: Language,
|
||||||
#[case] textual_date: &str,
|
#[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]
|
#[test]
|
||||||
fn t_parse_video_duration() {
|
fn t_parse_video_duration() {
|
||||||
let json_path = path!(*TESTFILES / "dict" / "video_duration_samples.json");
|
let json_path = path!(*TESTFILES / "dict" / "video_duration_samples.json");
|
||||||
|
|
|
||||||
87
testfiles/dict/cldr_data/collect_day_names.js
Normal file
87
testfiles/dict/cldr_data/collect_day_names.js
Normal 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));
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cldr-dates-modern": "^43.0.0",
|
"cldr-dates-modern": "^45.0.0",
|
||||||
"cldr-numbers-modern": "^43.0.0"
|
"cldr-numbers-modern": "^45.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
334
testfiles/dict/history_date_samples.json
Normal file
334
testfiles/dict/history_date_samples.json
Normal 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": "O‘tgan 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ use std::fmt::Display;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
use rustypipe::model::TrackType;
|
use rustypipe::model::{HistoryItem, TrackItem, TrackType, VideoItem};
|
||||||
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
||||||
use time::{macros::date, OffsetDateTime};
|
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();
|
let videos = rp.query().history().await.unwrap();
|
||||||
assert_next_items(videos, rp.query(), 100).await;
|
assert_next_history(videos, rp.query(), 100).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
|
@ -2762,7 +2762,7 @@ async fn history_search(rp: RustyPipe, cookie_auth_enabled: bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let videos = rp.query().history_search("a").await.unwrap();
|
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]
|
#[rstest]
|
||||||
|
|
@ -2817,7 +2817,7 @@ async fn music_history(rp: RustyPipe, cookie_auth_enabled: bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = rp.query().music_history().await.unwrap();
|
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]
|
#[rstest]
|
||||||
|
|
@ -2998,6 +2998,30 @@ async fn assert_next_items<T: FromYtItem, Q: AsRef<RustyPipeQuery>>(
|
||||||
assert_gte(p.items.len(), n_items, "items");
|
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]
|
#[track_caller]
|
||||||
fn assert_frameset(frameset: &Frameset) {
|
fn assert_frameset(frameset: &Frameset) {
|
||||||
assert_gte(frameset.frame_height, 20, "frame height");
|
assert_gte(frameset.frame_height, 20, "frame height");
|
||||||
|
|
|
||||||
Reference in a new issue