feat: add history item dates, extend timeago parser
This commit is contained in:
parent
65ada37214
commit
320a8c2c24
28 changed files with 6507 additions and 2160 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,4 +4,4 @@
|
|||
*.snap.new
|
||||
|
||||
rustypipe_reports
|
||||
rustypipe_cache.json
|
||||
rustypipe_cache*.json
|
||||
|
|
|
|||
2
Justfile
2
Justfile
|
|
@ -28,7 +28,7 @@ testintl:
|
|||
for YT_LANG in "${LANGUAGES[@]}"; do
|
||||
echo "---TESTS FOR $YT_LANG ---"
|
||||
|
||||
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast ---retries 1 --test-threads 4 --test youtube -E 'not test(/^resolve/)'; then
|
||||
if YT_LANG="$YT_LANG" cargo nextest run --no-fail-fast --retries 1 --test-threads 4 --test youtube -E 'not test(/^resolve/)'; then
|
||||
echo "--- $YT_LANG COMPLETED ---"
|
||||
else
|
||||
echo "--- $YT_LANG FAILED ---"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ struct Cli {
|
|||
/// YouTube content country
|
||||
#[clap(long, global = true)]
|
||||
country: Option<String>,
|
||||
/// Use authentication
|
||||
#[clap(long, global = true)]
|
||||
auth: bool,
|
||||
#[clap(long, global = true)]
|
||||
/// RustyPipe cache file
|
||||
cache_file: Option<PathBuf>,
|
||||
|
|
@ -653,6 +656,9 @@ async fn run() -> anyhow::Result<()> {
|
|||
if let Some(country) = cli.country {
|
||||
rp = rp.country(Country::from_str(&country.to_ascii_uppercase()).expect("invalid country"));
|
||||
}
|
||||
if cli.auth {
|
||||
rp = rp.authenticated();
|
||||
}
|
||||
let rp = rp.build()?;
|
||||
|
||||
match cli.command {
|
||||
|
|
|
|||
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>) {
|
||||
static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w?)$").unwrap());
|
||||
static TU_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\d*)(\w*)$").unwrap());
|
||||
match TU_PATTERN.captures(tu) {
|
||||
Some(cap) => (
|
||||
cap.get(1).unwrap().as_str().parse().unwrap_or(1),
|
||||
|
|
@ -22,6 +22,8 @@ fn parse_tu(tu: &str) -> (u8, Option<TimeUnit>) {
|
|||
"W" => Some(TimeUnit::Week),
|
||||
"M" => Some(TimeUnit::Month),
|
||||
"Y" => Some(TimeUnit::Year),
|
||||
"Wl" => Some(TimeUnit::LastWeek),
|
||||
"Wd" => Some(TimeUnit::LastWeekday),
|
||||
"" => None,
|
||||
_ => panic!("invalid time unit: {tu}"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
mod abtest;
|
||||
mod collect_album_types;
|
||||
mod collect_chan_prefixes;
|
||||
mod collect_history_dates;
|
||||
mod collect_large_numbers;
|
||||
mod collect_playlist_dates;
|
||||
mod collect_video_dates;
|
||||
|
|
@ -30,8 +31,10 @@ enum Commands {
|
|||
CollectAlbumTypes,
|
||||
CollectVideoDurations,
|
||||
CollectVideoDates,
|
||||
CollectHistoryDates,
|
||||
CollectChanPrefixes,
|
||||
ParsePlaylistDates,
|
||||
ParseHistoryDates,
|
||||
ParseLargeNumbers,
|
||||
ParseAlbumTypes,
|
||||
ParseVideoDurations,
|
||||
|
|
@ -68,10 +71,14 @@ async fn main() {
|
|||
Commands::CollectVideoDates => {
|
||||
collect_video_dates::collect_video_dates(cli.concurrency).await;
|
||||
}
|
||||
Commands::CollectHistoryDates => {
|
||||
collect_history_dates::collect_dates().await;
|
||||
}
|
||||
Commands::CollectChanPrefixes => {
|
||||
collect_chan_prefixes::collect_chan_prefixes().await;
|
||||
}
|
||||
Commands::ParsePlaylistDates => collect_playlist_dates::write_samples_to_dict(),
|
||||
Commands::ParseHistoryDates => collect_history_dates::write_samples_to_dict(),
|
||||
Commands::ParseLargeNumbers => collect_large_numbers::write_samples_to_dict(),
|
||||
Commands::ParseAlbumTypes => collect_album_types::write_samples_to_dict(),
|
||||
Commands::ParseVideoDurations => collect_video_durations::parse_video_durations(),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ pub enum TimeUnit {
|
|||
Week,
|
||||
Month,
|
||||
Year,
|
||||
LastWeek,
|
||||
LastWeekday,
|
||||
}
|
||||
|
||||
impl TimeUnit {
|
||||
|
|
@ -100,6 +102,8 @@ impl TimeUnit {
|
|||
TimeUnit::Week => "W",
|
||||
TimeUnit::Month => "M",
|
||||
TimeUnit::Year => "Y",
|
||||
TimeUnit::LastWeek => "Wl",
|
||||
TimeUnit::LastWeekday => "Wd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ pub fn filter_datestr(string: &str) -> String {
|
|||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c == '\u{200b}' || c.is_ascii_digit() {
|
||||
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
|
||||
None
|
||||
} else if c == '-' {
|
||||
Some(' ')
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ use crate::{
|
|||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
ChannelItem, VideoItem,
|
||||
ChannelItem, HistoryItem, VideoItem,
|
||||
},
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use self::response::YouTubeListMapper;
|
||||
|
||||
use super::{MapRespOptions, QContinuation};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QHistorySearch<'a> {
|
||||
|
|
@ -24,7 +28,7 @@ impl RustyPipeQuery {
|
|||
///
|
||||
/// Requires authentication cookies.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn history(&self) -> Result<Paginator<VideoItem>, Error> {
|
||||
pub async fn history(&self) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
|
||||
let request_body = QBrowse {
|
||||
browse_id: "FEhistory",
|
||||
};
|
||||
|
|
@ -41,6 +45,34 @@ impl RustyPipeQuery {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Get more YouTube history items from the given continuation token
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn history_continuation<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
ctoken: S,
|
||||
visitor_data: Option<&str>,
|
||||
) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
|
||||
let ctoken = ctoken.as_ref();
|
||||
let request_body = QContinuation {
|
||||
continuation: ctoken,
|
||||
};
|
||||
|
||||
self.clone()
|
||||
.authenticated()
|
||||
.execute_request_ctx::<response::Continuation, _, _>(
|
||||
ClientType::Desktop,
|
||||
"history_continuation",
|
||||
ctoken,
|
||||
"browse",
|
||||
&request_body,
|
||||
MapRespOptions {
|
||||
visitor_data,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Search the YouTube playback history of the current user
|
||||
///
|
||||
/// Requires authentication cookies.
|
||||
|
|
@ -48,7 +80,7 @@ impl RustyPipeQuery {
|
|||
pub async fn history_search<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
query: S,
|
||||
) -> Result<Paginator<VideoItem>, Error> {
|
||||
) -> Result<Paginator<HistoryItem<VideoItem>>, Error> {
|
||||
let query = query.as_ref();
|
||||
let request_body = QHistorySearch {
|
||||
browse_id: "FEhistory",
|
||||
|
|
@ -104,6 +136,65 @@ impl RustyPipeQuery {
|
|||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> {
|
||||
let items = self
|
||||
.contents
|
||||
.two_column_browse_results_renderer
|
||||
.contents
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(ExtractionError::InvalidData(
|
||||
"twoColumnBrowseResultsRenderer empty".into(),
|
||||
))?
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut map_res = MapResult {
|
||||
warnings: items.warnings,
|
||||
..Default::default()
|
||||
};
|
||||
let mut ctoken = None;
|
||||
for item in items.c {
|
||||
match item {
|
||||
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
|
||||
let mut mapper = YouTubeListMapper::<VideoItem>::new(ctx.lang);
|
||||
mapper.map_response(contents);
|
||||
mapper.conv_history_items(
|
||||
header.map(|h| h.item_section_header_renderer.title),
|
||||
&mut map_res,
|
||||
);
|
||||
}
|
||||
response::YouTubeListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
if ctoken.is_none() {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
crate::model::paginator::ContinuationEndpoint::Browse,
|
||||
true,
|
||||
),
|
||||
warnings: map_res.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<VideoItem>> for response::History {
|
||||
fn map_response(
|
||||
self,
|
||||
|
|
@ -131,7 +222,7 @@ impl MapResponse<Paginator<VideoItem>> for response::History {
|
|||
None,
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
None,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
crate::model::paginator::ContinuationEndpoint::Browse,
|
||||
true,
|
||||
),
|
||||
|
|
@ -145,29 +236,47 @@ mod tests {
|
|||
use std::{fs::File, io::BufReader};
|
||||
|
||||
use path_macro::path;
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::util::tests::TESTFILES;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[rstest]
|
||||
#[case::history("history")]
|
||||
#[case::subscription_feed("subscription_feed")]
|
||||
fn map_history(#[case] name: &str) {
|
||||
let json_path = path!(*TESTFILES / "history" / format!("{name}.json"));
|
||||
#[test]
|
||||
fn map_history() {
|
||||
let json_path = path!(*TESTFILES / "history" / "history.json");
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let history: response::History =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res = history.map_response(&MapRespCtx::test("")).unwrap();
|
||||
let map_res: MapResult<Paginator<HistoryItem<VideoItem>>> =
|
||||
history.map_response(&MapRespCtx::test("")).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(format!("map_{name}"), map_res.c, {
|
||||
insta::assert_ron_snapshot!(map_res.c, {
|
||||
".items[].playback_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_subscription_feed() {
|
||||
let json_path = path!(*TESTFILES / "history" / "subscription_feed.json");
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
|
||||
let history: response::History =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
let map_res: MapResult<Paginator<VideoItem>> =
|
||||
history.map_response(&MapRespCtx::test("")).unwrap();
|
||||
|
||||
assert!(
|
||||
map_res.warnings.is_empty(),
|
||||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(map_res.c, {
|
||||
".items[].publish_date" => "[date]",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -811,6 +811,16 @@ impl RustyPipeBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
/// Enable authentication for all requests
|
||||
///
|
||||
/// Depending on the client type RustyPipe uses either the authentication cookie or the
|
||||
/// OAuth token to authenticate requests.
|
||||
#[must_use]
|
||||
pub fn authenticated(mut self) -> Self {
|
||||
self.default_opts.auth = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable authentication for all requests
|
||||
#[must_use]
|
||||
pub fn unauthenticated(mut self) -> Self {
|
||||
|
|
|
|||
|
|
@ -113,12 +113,12 @@ impl MapResponse<MusicCharts> for response::MusicCharts {
|
|||
});
|
||||
|
||||
let mapped_top = mapper_top.conv_items::<TrackItem>();
|
||||
let mut mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
||||
let mut mapped_other = mapper_other.group_items();
|
||||
let mapped_trending = mapper_trending.conv_items::<TrackItem>();
|
||||
let mapped_other = mapper_other.group_items();
|
||||
|
||||
let mut warnings = mapped_top.warnings;
|
||||
warnings.append(&mut mapped_trending.warnings);
|
||||
warnings.append(&mut mapped_other.warnings);
|
||||
warnings.extend(mapped_trending.warnings);
|
||||
warnings.extend(mapped_other.warnings);
|
||||
|
||||
Ok(MapResult {
|
||||
c: MusicCharts {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
response::{self, music_item::MusicListMapper},
|
||||
|
|
@ -6,19 +8,19 @@ use crate::{
|
|||
error::{Error, ExtractionError},
|
||||
model::{
|
||||
paginator::{ContinuationEndpoint, Paginator},
|
||||
AlbumItem, ArtistItem, MusicPlaylistItem, TrackItem,
|
||||
AlbumItem, ArtistItem, HistoryItem, MusicPlaylistItem, TrackItem,
|
||||
},
|
||||
serializer::MapResult,
|
||||
};
|
||||
|
||||
use super::MapRespCtx;
|
||||
use super::{MapRespCtx, MapRespOptions, QContinuation};
|
||||
|
||||
impl RustyPipeQuery {
|
||||
/// Get a list of tracks from YouTube Music which the current user recently played
|
||||
///
|
||||
/// Requires authentication cookies.
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_history(&self) -> Result<Paginator<TrackItem>, Error> {
|
||||
pub async fn music_history(&self) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
|
||||
let request_body = QBrowseParams {
|
||||
browse_id: "FEmusic_history",
|
||||
params: "oggECgIIAQ%3D%3D",
|
||||
|
|
@ -36,6 +38,34 @@ impl RustyPipeQuery {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Get more YouTube Music history items from the given continuation token
|
||||
#[tracing::instrument(skip(self), level = "error")]
|
||||
pub async fn music_history_continuation<S: AsRef<str> + Debug>(
|
||||
&self,
|
||||
ctoken: S,
|
||||
visitor_data: Option<&str>,
|
||||
) -> Result<Paginator<HistoryItem<TrackItem>>, Error> {
|
||||
let ctoken = ctoken.as_ref();
|
||||
let request_body = QContinuation {
|
||||
continuation: ctoken,
|
||||
};
|
||||
|
||||
self.clone()
|
||||
.authenticated()
|
||||
.execute_request_ctx::<response::MusicContinuation, _, _>(
|
||||
ClientType::Desktop,
|
||||
"history_continuation",
|
||||
ctoken,
|
||||
"browse",
|
||||
&request_body,
|
||||
MapRespOptions {
|
||||
visitor_data,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a list of YouTube Music artists which the current user subscribed to
|
||||
///
|
||||
/// Requires authentication cookies.
|
||||
|
|
@ -99,11 +129,11 @@ impl RustyPipeQuery {
|
|||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
||||
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicHistory {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
|
||||
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
|
||||
let contents = match self.contents {
|
||||
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
|
||||
c.contents
|
||||
|
|
@ -120,7 +150,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
|||
} => secondary_contents.section_list_renderer,
|
||||
};
|
||||
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
let mut map_res = MapResult::default();
|
||||
|
||||
for shelf in contents.contents {
|
||||
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
|
||||
|
|
@ -128,11 +158,11 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
|||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
mapper.map_response(shelf.contents);
|
||||
mapper.conv_history_items(shelf.title, &mut map_res);
|
||||
}
|
||||
|
||||
let map_res = mapper.conv_items();
|
||||
|
||||
let ctoken = contents
|
||||
.continuations
|
||||
.into_iter()
|
||||
|
|
@ -144,7 +174,7 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
|
|||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
None,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
true,
|
||||
),
|
||||
|
|
@ -177,6 +207,8 @@ mod tests {
|
|||
"deserialization/mapping warnings: {:?}",
|
||||
map_res.warnings
|
||||
);
|
||||
insta::assert_ron_snapshot!(map_res.c);
|
||||
insta::assert_ron_snapshot!(map_res.c, {
|
||||
".items[].playback_date" => "[date]",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ use crate::model::{
|
|||
traits::FromYtItem,
|
||||
Comment, MusicItem, YouTubeItem,
|
||||
};
|
||||
use crate::model::{HistoryItem, TrackItem, VideoItem};
|
||||
use crate::serializer::MapResult;
|
||||
|
||||
use self::response::YouTubeListItem;
|
||||
|
||||
use super::response::music_item::{map_queue_item, MusicListMapper, PlaylistPanelVideo};
|
||||
use super::{
|
||||
response, ClientType, MapRespCtx, MapRespOptions, MapResponse, QContinuation, RustyPipeQuery,
|
||||
|
|
@ -95,38 +98,44 @@ fn map_ytm_paginator<T: FromYtItem>(
|
|||
}
|
||||
}
|
||||
|
||||
fn continuation_items(response: response::Continuation) -> MapResult<Vec<YouTubeListItem>> {
|
||||
response
|
||||
.on_response_received_actions
|
||||
.and_then(|actions| {
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| action.append_continuation_items_action.continuation_items)
|
||||
.reduce(|mut acc, mut items| {
|
||||
acc.c.append(&mut items.c);
|
||||
acc.warnings.append(&mut items.warnings);
|
||||
acc
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
response
|
||||
.continuation_contents
|
||||
.map(|contents| contents.rich_grid_continuation.contents)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Paginator<YouTubeItem>>, ExtractionError> {
|
||||
let items = self
|
||||
.on_response_received_actions
|
||||
.and_then(|actions| {
|
||||
actions
|
||||
.into_iter()
|
||||
.map(|action| action.append_continuation_items_action.continuation_items)
|
||||
.reduce(|mut acc, mut items| {
|
||||
acc.c.append(&mut items.c);
|
||||
acc.warnings.append(&mut items.warnings);
|
||||
acc
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.continuation_contents
|
||||
.map(|contents| contents.rich_grid_continuation.contents)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let estimated_results = self.estimated_results;
|
||||
let items = continuation_items(self);
|
||||
|
||||
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
|
||||
mapper.map_response(items);
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
self.estimated_results,
|
||||
estimated_results,
|
||||
mapper.items,
|
||||
mapper.ctoken,
|
||||
None,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::Browse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
|
|
@ -201,7 +210,99 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
|||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
warnings: map_res.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Paginator<HistoryItem<VideoItem>>>, ExtractionError> {
|
||||
let mut map_res = MapResult::default();
|
||||
let mut ctoken = None;
|
||||
|
||||
let items = continuation_items(self);
|
||||
for item in items.c {
|
||||
match item {
|
||||
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
|
||||
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
|
||||
mapper.map_response(contents);
|
||||
mapper.conv_history_items(
|
||||
header.map(|h| h.item_section_header_renderer.title),
|
||||
&mut map_res,
|
||||
);
|
||||
}
|
||||
response::YouTubeListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
if ctoken.is_none() {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::Browse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
warnings: map_res.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MapResponse<Paginator<HistoryItem<TrackItem>>> for response::MusicContinuation {
|
||||
fn map_response(
|
||||
self,
|
||||
ctx: &MapRespCtx<'_>,
|
||||
) -> Result<MapResult<Paginator<HistoryItem<TrackItem>>>, ExtractionError> {
|
||||
let mut map_res = MapResult::default();
|
||||
let mut continuations = Vec::new();
|
||||
|
||||
let mut map_shelf = |shelf: response::music_item::MusicShelf| {
|
||||
let mut mapper = MusicListMapper::new(ctx.lang);
|
||||
mapper.map_response(shelf.contents);
|
||||
mapper.conv_history_items(shelf.title, &mut map_res);
|
||||
continuations.extend(shelf.continuations);
|
||||
};
|
||||
|
||||
match self.continuation_contents {
|
||||
Some(response::music_item::ContinuationContents::MusicShelfContinuation(shelf)) => {
|
||||
map_shelf(shelf);
|
||||
}
|
||||
Some(response::music_item::ContinuationContents::SectionListContinuation(contents)) => {
|
||||
for c in contents.contents {
|
||||
if let response::music_item::ItemSection::MusicShelfRenderer(shelf) = c {
|
||||
map_shelf(shelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let ctoken = continuations
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|cont| cont.next_continuation_data.continuation);
|
||||
|
||||
Ok(MapResult {
|
||||
c: Paginator::new_ext(
|
||||
None,
|
||||
map_res.c,
|
||||
ctoken,
|
||||
ctx.visitor_data.map(str::to_owned),
|
||||
ContinuationEndpoint::MusicBrowse,
|
||||
ctx.authenticated,
|
||||
),
|
||||
|
|
@ -213,11 +314,6 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
|
|||
impl<T: FromYtItem> Paginator<T> {
|
||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
||||
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
|
||||
// let mut q = query.as_ref().clone();
|
||||
// if self.authenticated {
|
||||
// q = q.authenticated();
|
||||
// }
|
||||
|
||||
Ok(match &self.ctoken {
|
||||
Some(ctoken) => {
|
||||
let q = if self.authenticated {
|
||||
|
|
@ -319,6 +415,36 @@ impl Paginator<Comment> {
|
|||
}
|
||||
}
|
||||
|
||||
impl Paginator<HistoryItem<VideoItem>> {
|
||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
||||
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
|
||||
Ok(match &self.ctoken {
|
||||
Some(ctoken) => Some(
|
||||
query
|
||||
.as_ref()
|
||||
.history_continuation(ctoken, self.visitor_data.as_deref())
|
||||
.await?,
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Paginator<HistoryItem<TrackItem>> {
|
||||
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
|
||||
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
|
||||
Ok(match &self.ctoken {
|
||||
Some(ctoken) => Some(
|
||||
query
|
||||
.as_ref()
|
||||
.music_history_continuation(ctoken, self.visitor_data.as_deref())
|
||||
.await?,
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! paginator {
|
||||
($entity_type:ty) => {
|
||||
impl Paginator<$entity_type> {
|
||||
|
|
@ -400,6 +526,8 @@ macro_rules! paginator {
|
|||
}
|
||||
|
||||
paginator!(Comment);
|
||||
paginator!(HistoryItem<VideoItem>);
|
||||
paginator!(HistoryItem<TrackItem>);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,14 @@ pub(crate) struct TextBox {
|
|||
pub text: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SimpleHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkip
|
|||
use crate::{
|
||||
model::{
|
||||
self, traits::FromYtItem, AlbumId, AlbumItem, AlbumType, ArtistId, ArtistItem, ChannelId,
|
||||
MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
||||
HistoryItem, MusicItem, MusicItemType, MusicPlaylistItem, TrackItem, UserItem,
|
||||
},
|
||||
param::Language,
|
||||
serializer::{
|
||||
|
|
@ -18,7 +18,7 @@ use super::{
|
|||
url_endpoint::{
|
||||
BrowseEndpointWrap, MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
|
||||
},
|
||||
ContentsRenderer, MusicContinuationData, Thumbnails, ThumbnailsWrap,
|
||||
ContentsRenderer, MusicContinuationData, SimpleHeaderRenderer, Thumbnails, ThumbnailsWrap,
|
||||
};
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -39,6 +39,8 @@ pub(crate) enum ItemSection {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct MusicShelf {
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub title: Option<String>,
|
||||
/// Playlist ID (only for playlists)
|
||||
pub playlist_id: Option<String>,
|
||||
pub contents: MapResult<Vec<MusicResponseItem>>,
|
||||
|
|
@ -396,15 +398,7 @@ pub(crate) struct GridRenderer {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GridHeader {
|
||||
pub grid_header_renderer: GridHeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GridHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
pub grid_header_renderer: SimpleHeaderRenderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -419,14 +413,6 @@ pub(crate) struct SimpleHeader {
|
|||
pub music_header_renderer: SimpleHeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SimpleHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum TrackBadge {
|
||||
|
|
@ -1257,6 +1243,26 @@ impl MusicListMapper {
|
|||
warnings: self.warnings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conv_history_items(
|
||||
self,
|
||||
date_txt: Option<String>,
|
||||
res: &mut MapResult<Vec<HistoryItem<TrackItem>>>,
|
||||
) {
|
||||
res.warnings.extend(self.warnings);
|
||||
res.c.extend(
|
||||
self.items
|
||||
.into_iter()
|
||||
.filter_map(TrackItem::from_ytm_item)
|
||||
.map(|item| HistoryItem {
|
||||
item,
|
||||
playback_date: date_txt.as_deref().and_then(|s| {
|
||||
timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings)
|
||||
}),
|
||||
playback_date_txt: date_txt.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map TextComponents containing artist names to a list of artists and a 'Various Artists' flag
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ use serde_with::{
|
|||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, Thumbnails};
|
||||
use super::{
|
||||
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
|
||||
Thumbnails,
|
||||
};
|
||||
use crate::{
|
||||
model::{Channel, ChannelItem, ChannelTag, PlaylistItem, VideoItem, YouTubeItem},
|
||||
model::{Channel, ChannelItem, ChannelTag, HistoryItem, PlaylistItem, VideoItem, YouTubeItem},
|
||||
param::Language,
|
||||
serializer::{
|
||||
text::{AttributedText, Text, TextComponent},
|
||||
|
|
@ -63,6 +66,7 @@ pub(crate) enum YouTubeListItem {
|
|||
/// GridRenderer: contains videos on channel page
|
||||
#[serde(alias = "expandedShelfContentsRenderer", alias = "gridRenderer")]
|
||||
ItemSectionRenderer {
|
||||
header: Option<ItemSectionHeader>,
|
||||
#[serde(alias = "items")]
|
||||
contents: MapResult<Vec<YouTubeListItem>>,
|
||||
},
|
||||
|
|
@ -294,6 +298,12 @@ pub(crate) struct YouTubeListRenderer {
|
|||
pub contents: MapResult<Vec<YouTubeListItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ItemSectionHeader {
|
||||
pub item_section_header_renderer: SimpleHeaderRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -833,7 +843,7 @@ impl YouTubeListMapper<YouTubeItem> {
|
|||
YouTubeListItem::RichItemRenderer { content } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||
}
|
||||
|
|
@ -881,7 +891,7 @@ impl YouTubeListMapper<VideoItem> {
|
|||
YouTubeListItem::RichItemRenderer { content } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||
}
|
||||
|
|
@ -893,6 +903,23 @@ impl YouTubeListMapper<VideoItem> {
|
|||
self.warnings.append(&mut res.warnings);
|
||||
res.c.into_iter().for_each(|item| self.map_item(item));
|
||||
}
|
||||
|
||||
pub(crate) fn conv_history_items(
|
||||
self,
|
||||
date_txt: Option<String>,
|
||||
res: &mut MapResult<Vec<HistoryItem<VideoItem>>>,
|
||||
) {
|
||||
res.warnings.extend(self.warnings);
|
||||
res.c.extend(self.items.into_iter().map(|item| {
|
||||
HistoryItem {
|
||||
item,
|
||||
playback_date: date_txt.as_deref().and_then(|s| {
|
||||
timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings)
|
||||
}),
|
||||
playback_date_txt: date_txt.clone(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl YouTubeListMapper<PlaylistItem> {
|
||||
|
|
@ -916,7 +943,7 @@ impl YouTubeListMapper<PlaylistItem> {
|
|||
YouTubeListItem::RichItemRenderer { content } => {
|
||||
self.map_item(*content);
|
||||
}
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents } => {
|
||||
YouTubeListItem::ItemSectionRenderer { mut contents, .. } => {
|
||||
self.warnings.append(&mut contents.warnings);
|
||||
contents.c.into_iter().for_each(|it| self.map_item(it));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,240 +5,260 @@ expression: map_res.c
|
|||
Paginator(
|
||||
count: None,
|
||||
items: [
|
||||
VideoItem(
|
||||
id: "mPshy_DWxfo",
|
||||
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
|
||||
duration: Some(6),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACMQBEG5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLDnRYKBX4qMlA54i-q3W7w1WvGApg",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCPYBEIoBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDza_6r3345q6SBZvGm292mOobNPg",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCNACELwBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDySwxxAy2hfw2YcAKs6ERLhzPTkQ",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCM7OXM6t80a3e3tzQDWxwEA",
|
||||
name: "Ari",
|
||||
avatar: [
|
||||
HistoryItem(
|
||||
item: VideoItem(
|
||||
id: "mPshy_DWxfo",
|
||||
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
|
||||
duration: Some(6),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACMQBEG5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLDnRYKBX4qMlA54i-q3W7w1WvGApg",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCPYBEIoBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDza_6r3345q6SBZvGm292mOobNPg",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCNACELwBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDySwxxAy2hfw2YcAKs6ERLhzPTkQ",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
verification: none,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
publish_date_txt: None,
|
||||
view_count: Some(15),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: None,
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCM7OXM6t80a3e3tzQDWxwEA",
|
||||
name: "Ari",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
),
|
||||
],
|
||||
verification: none,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: None,
|
||||
publish_date_txt: None,
|
||||
view_count: Some(15),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: None,
|
||||
),
|
||||
playback_date: "[date]",
|
||||
playback_date_txt: Some("Yesterday"),
|
||||
),
|
||||
VideoItem(
|
||||
id: "SRWatgS077k",
|
||||
name: "My Time at \"Camp Operetta\"",
|
||||
duration: Some(578),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBgxonLRY-4QQ1-jR3Xen-fAZcHHQ",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBOk1abznwO5Bm0_m5YXMFkU0JSog",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLCQt7cAJuE-W8t1TnQnSe5EVbsw8A",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCGwu0nbY2wSkW8N-cghnLpA",
|
||||
name: "JaidenAnimations",
|
||||
avatar: [
|
||||
HistoryItem(
|
||||
item: VideoItem(
|
||||
id: "SRWatgS077k",
|
||||
name: "My Time at \"Camp Operetta\"",
|
||||
duration: Some(578),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBgxonLRY-4QQ1-jR3Xen-fAZcHHQ",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBOk1abznwO5Bm0_m5YXMFkU0JSog",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLCQt7cAJuE-W8t1TnQnSe5EVbsw8A",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
publish_date_txt: None,
|
||||
view_count: Some(23907328),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("What can I say other than that was one heck of a time\n\nScribble Showdown Tickets: https://www.scribbleshowdown.com/\n\n\n♥ The Team ♥\nDenny: https://www.instagram.com/90percentknuckles/\nAtrox:..."),
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCGwu0nbY2wSkW8N-cghnLpA",
|
||||
name: "JaidenAnimations",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: None,
|
||||
publish_date_txt: None,
|
||||
view_count: Some(23907328),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("What can I say other than that was one heck of a time\n\nScribble Showdown Tickets: https://www.scribbleshowdown.com/\n\n\n♥ The Team ♥\nDenny: https://www.instagram.com/90percentknuckles/\nAtrox:..."),
|
||||
),
|
||||
playback_date: "[date]",
|
||||
playback_date_txt: Some("Yesterday"),
|
||||
),
|
||||
VideoItem(
|
||||
id: "kTxlkDoqArA",
|
||||
name: "Wie Cartoons Früher gemacht wurden!",
|
||||
duration: Some(283),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAgu_Ad1pFCsa3jINV1ocaVOQWOXg",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDkOVQbyZlrZ_jbdkSzUd5RiobObA",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA5cnUH03I2lg1-FOJ01njh8UOJEw",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCxTHCMaxURhapisCMBv8y0A",
|
||||
name: "Plankton",
|
||||
avatar: [
|
||||
HistoryItem(
|
||||
item: VideoItem(
|
||||
id: "kTxlkDoqArA",
|
||||
name: "Wie Cartoons Früher gemacht wurden!",
|
||||
duration: Some(283),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAgu_Ad1pFCsa3jINV1ocaVOQWOXg",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDkOVQbyZlrZ_jbdkSzUd5RiobObA",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA5cnUH03I2lg1-FOJ01njh8UOJEw",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
publish_date_txt: None,
|
||||
view_count: Some(390010),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("Folgt mir auf Instagram!\nhttps://instagram.com/plankton.gif \n\nÜBER DEN KANAL:\nRede viel wenn der Tag lang ist"),
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCxTHCMaxURhapisCMBv8y0A",
|
||||
name: "Plankton",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: None,
|
||||
publish_date_txt: None,
|
||||
view_count: Some(390010),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("Folgt mir auf Instagram!\nhttps://instagram.com/plankton.gif \n\nÜBER DEN KANAL:\nRede viel wenn der Tag lang ist"),
|
||||
),
|
||||
playback_date: "[date]",
|
||||
playback_date_txt: Some("Yesterday"),
|
||||
),
|
||||
VideoItem(
|
||||
id: "oIVSKQ8NMqk",
|
||||
name: "What I learned on highschool swim",
|
||||
duration: Some(620),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAEzpr1xBI-8jJwZz72NHj9VKyefA",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBNzC8nvKtO7fmqzavWemou7QOLOg",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA1_VgkVeq4ELmrQ8a4vhtJhg6TMA",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
|
||||
name: "illymation",
|
||||
avatar: [
|
||||
HistoryItem(
|
||||
item: VideoItem(
|
||||
id: "oIVSKQ8NMqk",
|
||||
name: "What I learned on highschool swim",
|
||||
duration: Some(620),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAEzpr1xBI-8jJwZz72NHj9VKyefA",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBNzC8nvKtO7fmqzavWemou7QOLOg",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA1_VgkVeq4ELmrQ8a4vhtJhg6TMA",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
publish_date_txt: None,
|
||||
view_count: Some(6491367),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("okay, so I wasn\'t the BEST ... but I tried my best!\n▶ Black Friday Merch sale: https://www.hereforthechaos.com\n▶ SNEAK PEEKS ON PATREON! http://patreon.com/illymation\n\n▶ BG ARTIST: Ingrid..."),
|
||||
channel: Some(ChannelTag(
|
||||
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
|
||||
name: "illymation",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: None,
|
||||
publish_date_txt: None,
|
||||
view_count: Some(6491367),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("okay, so I wasn\'t the BEST ... but I tried my best!\n▶ Black Friday Merch sale: https://www.hereforthechaos.com\n▶ SNEAK PEEKS ON PATREON! http://patreon.com/illymation\n\n▶ BG ARTIST: Ingrid..."),
|
||||
),
|
||||
playback_date: "[date]",
|
||||
playback_date_txt: Some("Yesterday"),
|
||||
),
|
||||
VideoItem(
|
||||
id: "X30eFeqrHJo",
|
||||
name: "My Last Week of University!",
|
||||
duration: Some(659),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAE7jwC0MudkKK-I1nyGvCheljvtQ",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLAfIZ4htcNHP3RWahpR4XM7U-UTFQ",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDYMLObTeyxHK_PewK4Rwk3V-2KGw",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
channel: Some(ChannelTag(
|
||||
id: "UC6a8lp6vaCMhUVXPyynhjUA",
|
||||
name: "Ruby Granger",
|
||||
avatar: [
|
||||
HistoryItem(
|
||||
item: VideoItem(
|
||||
id: "X30eFeqrHJo",
|
||||
name: "My Last Week of University!",
|
||||
duration: Some(659),
|
||||
thumbnail: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
|
||||
width: 168,
|
||||
height: 94,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAE7jwC0MudkKK-I1nyGvCheljvtQ",
|
||||
width: 196,
|
||||
height: 110,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLAfIZ4htcNHP3RWahpR4XM7U-UTFQ",
|
||||
width: 246,
|
||||
height: 138,
|
||||
),
|
||||
Thumbnail(
|
||||
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDYMLObTeyxHK_PewK4Rwk3V-2KGw",
|
||||
width: 336,
|
||||
height: 188,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: "[date]",
|
||||
publish_date_txt: None,
|
||||
view_count: Some(132844),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("This first year has been somewhat hectic, and certainly was tricky in the first semester; however, I have enjoyed it immensely and, as I said, am sad that this term has now ended. I am returning..."),
|
||||
channel: Some(ChannelTag(
|
||||
id: "UC6a8lp6vaCMhUVXPyynhjUA",
|
||||
name: "Ruby Granger",
|
||||
avatar: [
|
||||
Thumbnail(
|
||||
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
|
||||
width: 68,
|
||||
height: 68,
|
||||
),
|
||||
],
|
||||
verification: verified,
|
||||
subscriber_count: None,
|
||||
)),
|
||||
publish_date: None,
|
||||
publish_date_txt: None,
|
||||
view_count: Some(132844),
|
||||
is_live: false,
|
||||
is_short: false,
|
||||
is_upcoming: false,
|
||||
short_description: Some("This first year has been somewhat hectic, and certainly was tricky in the first semester; however, I have enjoyed it immensely and, as I said, am sad that this term has now ended. I am returning..."),
|
||||
),
|
||||
playback_date: "[date]",
|
||||
playback_date_txt: Some("Yesterday"),
|
||||
),
|
||||
],
|
||||
ctoken: Some("4qmFsgJMEglGRWhpc3RvcnkaKENBSjZHbmx5WVRWb2QyOU9RMmR6U1RSaGNXMTFkMWxSTms4M05WcFKaAhRicm93c2UtZmVlZEZFaGlzdG9yeQ%3D%3D"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1331,3 +1331,15 @@ pub struct MusicSearchSuggestion {
|
|||
/// Suggested music items
|
||||
pub items: Vec<MusicItem>,
|
||||
}
|
||||
|
||||
/// YouTube playback history entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct HistoryItem<T> {
|
||||
/// History item
|
||||
pub item: T,
|
||||
/// Playback date
|
||||
pub playback_date: Option<Date>,
|
||||
/// Textual playback date
|
||||
pub playback_date_txt: Option<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use time::{Date, Month, OffsetDateTime};
|
||||
use time::{Date, Duration, Month, OffsetDateTime};
|
||||
|
||||
/// Shift a date by the given number of months.
|
||||
/// Ambiguous month-ends are shifted backwards as necessary.
|
||||
|
|
@ -25,6 +25,11 @@ pub fn shift_years(date: Date, years: i32) -> Date {
|
|||
shift_months(date, years * 12)
|
||||
}
|
||||
|
||||
pub fn shift_weeks_mo(date: Date, weeks: i32) -> Date {
|
||||
let d = date + Duration::weeks(weeks.into());
|
||||
Date::from_iso_week_date(d.year(), d.iso_week(), time::Weekday::Monday).unwrap()
|
||||
}
|
||||
|
||||
/// Get the current datetime without milli/micro/nanoseconds
|
||||
pub fn now_sec() -> OffsetDateTime {
|
||||
OffsetDateTime::now_utc()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@ mod protobuf;
|
|||
pub mod dictionary;
|
||||
pub mod timeago;
|
||||
|
||||
pub use date::{now_sec, shift_months, shift_years};
|
||||
pub use date::{now_sec, shift_months, shift_weeks_mo, shift_years};
|
||||
pub use protobuf::{string_from_pb, ProtoBuilder};
|
||||
|
||||
use std::{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ pub enum TimeUnit {
|
|||
Week,
|
||||
Month,
|
||||
Year,
|
||||
LastWeek,
|
||||
LastWeekday,
|
||||
}
|
||||
|
||||
/// Value of a parsed TimeAgo token, used in the dictionary
|
||||
|
|
@ -86,10 +88,17 @@ impl TimeUnit {
|
|||
TimeUnit::Week => 7 * 24 * 3600,
|
||||
TimeUnit::Month => 30 * 24 * 3600,
|
||||
TimeUnit::Year => 365 * 24 * 3600,
|
||||
TimeUnit::LastWeekday | TimeUnit::LastWeek => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaToken {
|
||||
fn into_timeago(self) -> Option<TimeAgo> {
|
||||
self.unit.map(|unit| TimeAgo { n: self.n, unit })
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeAgo {
|
||||
fn secs(self) -> u32 {
|
||||
u32::from(self.n) * self.unit.secs()
|
||||
|
|
@ -119,6 +128,17 @@ impl From<TimeAgo> for OffsetDateTime {
|
|||
match ta.unit {
|
||||
TimeUnit::Month => ts.replace_date(util::shift_months(ts.date(), -i32::from(ta.n))),
|
||||
TimeUnit::Year => ts.replace_date(util::shift_years(ts.date(), -i32::from(ta.n))),
|
||||
TimeUnit::LastWeek => {
|
||||
ts.replace_date(util::shift_weeks_mo(ts.date(), -i32::from(ta.n)))
|
||||
}
|
||||
TimeUnit::LastWeekday => ts.replace_date(
|
||||
Date::from_iso_week_date(
|
||||
ts.year(),
|
||||
ts.iso_week(),
|
||||
time::Weekday::Monday.nth_next(ta.n),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
_ => ts - Duration::from(ta),
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +159,7 @@ fn filter_datestr(string: &str) -> String {
|
|||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if matches!(c, '\u{200b}' | '.') || c.is_ascii_digit() {
|
||||
if matches!(c, '\u{200b}' | '.' | ',') || c.is_ascii_digit() {
|
||||
None
|
||||
} else if c == '-' {
|
||||
Some(' ')
|
||||
|
|
@ -249,57 +269,86 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDa
|
|||
|
||||
let nums = util::parse_numeric_vec::<u16>(textual_date);
|
||||
|
||||
match nums.len() {
|
||||
0 => match TaTokenParser::new(&entry, by_char, true, &filtered_str).next() {
|
||||
Some(timeago) => Some(ParsedDate::Relative(timeago)),
|
||||
None => TaTokenParser::new(&entry, by_char, false, &filtered_str)
|
||||
.next()
|
||||
.map(ParsedDate::Relative),
|
||||
},
|
||||
1 => TaTokenParser::new(&entry, by_char, false, &filtered_str)
|
||||
.next()
|
||||
.map(|timeago| ParsedDate::Relative(timeago * nums[0] as u8)),
|
||||
2..=3 => {
|
||||
if nums.len() == entry.date_order.len() {
|
||||
let mut y: Option<u16> = None;
|
||||
let mut m: Option<u16> = None;
|
||||
let mut d: Option<u16> = None;
|
||||
|
||||
nums.iter()
|
||||
.enumerate()
|
||||
.for_each(|(i, n)| match entry.date_order[i] {
|
||||
DateCmp::Y => y = Some(*n),
|
||||
DateCmp::M => m = Some(*n),
|
||||
DateCmp::D => d = Some(*n),
|
||||
});
|
||||
|
||||
// Chinese/Japanese dont use textual months
|
||||
if m.is_none() && !by_char {
|
||||
m = parse_textual_month(&entry, &filtered_str).map(u16::from);
|
||||
}
|
||||
|
||||
match (y, m, d) {
|
||||
(Some(y), Some(m), Some(d)) => Month::try_from(m as u8)
|
||||
.ok()
|
||||
.and_then(|m| Date::from_calendar_date(y.into(), m, d as u8).ok())
|
||||
.map(ParsedDate::Absolute),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
if nums.is_empty() {
|
||||
entry
|
||||
.timeago_nd_tokens
|
||||
.get(&filtered_str)
|
||||
.and_then(|t| t.into_timeago())
|
||||
.or_else(|| TaTokenParser::new(&entry, by_char, true, &filtered_str).next())
|
||||
.or_else(|| TaTokenParser::new(&entry, by_char, false, &filtered_str).next())
|
||||
.map(ParsedDate::Relative)
|
||||
} else {
|
||||
if nums.len() == 1 {
|
||||
if let Some(timeago) = TaTokenParser::new(&entry, by_char, false, &filtered_str).next()
|
||||
{
|
||||
return Some(ParsedDate::Relative(timeago * nums[0] as u8));
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
|
||||
let mut date_order = entry.date_order;
|
||||
let with_day = if entry.date_order.len() == nums.len() {
|
||||
true
|
||||
} else if entry.date_order.len() - 1 == nums.len() {
|
||||
false
|
||||
} else if nums.len() == 1 {
|
||||
date_order = &[DateCmp::Y];
|
||||
false
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut y: Option<u16> = None;
|
||||
let mut m: Option<u16> = None;
|
||||
let mut d: Option<u16> = None;
|
||||
|
||||
let mut i = 0;
|
||||
for dc in date_order.iter() {
|
||||
match dc {
|
||||
DateCmp::Y => y = Some(nums[i]),
|
||||
DateCmp::M => m = Some(nums[i]),
|
||||
DateCmp::D => {
|
||||
if with_day {
|
||||
d = Some(nums[i]);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if m.is_none() {
|
||||
m = parse_textual_month(&entry, &filtered_str).map(u16::from);
|
||||
}
|
||||
|
||||
match (y, m, d) {
|
||||
(Some(y), Some(m), d) => Month::try_from(m as u8)
|
||||
.ok()
|
||||
.and_then(|m| Date::from_calendar_date(y.into(), m, d.unwrap_or(1) as u8).ok())
|
||||
.map(ParsedDate::Absolute),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a textual date (e.g. "29 minutes ago" or "Jul 2, 2014") into a Chrono DateTime object.
|
||||
/// Parse a textual date (e.g. "29 minutes ago" or "Jul 2, 2014") into a OffsetDateTime object.
|
||||
///
|
||||
/// Returns None if the date could not be parsed.
|
||||
pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option<OffsetDateTime> {
|
||||
parse_textual_date(lang, textual_date).map(OffsetDateTime::from)
|
||||
}
|
||||
|
||||
/// Parse a textual date (e.g. "29 minutes ago" "Jul 2, 2014") into a Date object.
|
||||
///
|
||||
/// Returns None if the date could not be parsed.
|
||||
pub fn parse_textual_date_to_d(
|
||||
lang: Language,
|
||||
textual_date: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<Date> {
|
||||
parse_textual_date_or_warn(lang, textual_date, warnings).map(OffsetDateTime::date)
|
||||
}
|
||||
|
||||
pub fn parse_textual_date_or_warn(
|
||||
lang: Language,
|
||||
textual_date: &str,
|
||||
|
|
@ -845,6 +894,8 @@ mod tests {
|
|||
"যোগ দিয়েছেন 24 সেপ, 2013",
|
||||
Some(ParsedDate::Absolute(date!(2013-9-24)))
|
||||
)]
|
||||
#[case(Language::Ja, "2023年7月", Some(ParsedDate::Absolute(date!(2023-07-01))))]
|
||||
#[case(Language::De, "Juli 2023", Some(ParsedDate::Absolute(date!(2023-07-01))))]
|
||||
fn t_parse_date(
|
||||
#[case] lang: Language,
|
||||
#[case] textual_date: &str,
|
||||
|
|
@ -949,6 +1000,39 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_parse_history_date_samples() {
|
||||
#[derive(Deserialize)]
|
||||
struct HistoryDates {
|
||||
this_week: String,
|
||||
last_week: String,
|
||||
}
|
||||
|
||||
let json_path = path!(*TESTFILES / "dict" / "history_date_samples.json");
|
||||
let json_file = File::open(json_path).unwrap();
|
||||
let date_samples: BTreeMap<Language, HistoryDates> =
|
||||
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||
|
||||
for (lang, samples) in date_samples {
|
||||
assert_eq!(
|
||||
parse_textual_date(lang, &samples.this_week),
|
||||
Some(ParsedDate::Relative(TimeAgo {
|
||||
n: 0,
|
||||
unit: TimeUnit::LastWeek
|
||||
})),
|
||||
"lang: {lang}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_textual_date(lang, &samples.last_week),
|
||||
Some(ParsedDate::Relative(TimeAgo {
|
||||
n: 1,
|
||||
unit: TimeUnit::LastWeek
|
||||
})),
|
||||
"lang: {lang}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_parse_video_duration() {
|
||||
let json_path = path!(*TESTFILES / "dict" / "video_duration_samples.json");
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"cldr-dates-modern": "^43.0.0",
|
||||
"cldr-numbers-modern": "^43.0.0"
|
||||
"cldr-dates-modern": "^45.0.0",
|
||||
"cldr-numbers-modern": "^45.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
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 rstest::{fixture, rstest};
|
||||
use rustypipe::model::TrackType;
|
||||
use rustypipe::model::{HistoryItem, TrackItem, TrackType, VideoItem};
|
||||
use rustypipe::param::{AlbumOrder, LANGUAGES};
|
||||
use time::{macros::date, OffsetDateTime};
|
||||
|
||||
|
|
@ -2751,7 +2751,7 @@ async fn history(rp: RustyPipe, cookie_auth_enabled: bool) {
|
|||
}
|
||||
|
||||
let videos = rp.query().history().await.unwrap();
|
||||
assert_next_items(videos, rp.query(), 100).await;
|
||||
assert_next_history(videos, rp.query(), 100).await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
|
@ -2762,7 +2762,7 @@ async fn history_search(rp: RustyPipe, cookie_auth_enabled: bool) {
|
|||
}
|
||||
|
||||
let videos = rp.query().history_search("a").await.unwrap();
|
||||
assert_next_items(videos, rp.query(), 5).await;
|
||||
assert_next_history(videos, rp.query(), 5).await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
|
@ -2817,7 +2817,7 @@ async fn music_history(rp: RustyPipe, cookie_auth_enabled: bool) {
|
|||
}
|
||||
|
||||
let tracks = rp.query().music_history().await.unwrap();
|
||||
assert_next_items(tracks, rp.query(), 150).await;
|
||||
assert_next_music_history(tracks, rp.query(), 150).await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
|
@ -2998,6 +2998,30 @@ async fn assert_next_items<T: FromYtItem, Q: AsRef<RustyPipeQuery>>(
|
|||
assert_gte(p.items.len(), n_items, "items");
|
||||
}
|
||||
|
||||
/// Assert that the history paginator produces at least n items
|
||||
async fn assert_next_history<Q: AsRef<RustyPipeQuery>>(
|
||||
paginator: Paginator<HistoryItem<VideoItem>>,
|
||||
query: Q,
|
||||
n_items: usize,
|
||||
) {
|
||||
let mut p = paginator;
|
||||
let query = query.as_ref();
|
||||
p.extend_limit(query, n_items).await.unwrap();
|
||||
assert_gte(p.items.len(), n_items, "items");
|
||||
}
|
||||
|
||||
/// Assert that the music history paginator produces at least n items
|
||||
async fn assert_next_music_history<Q: AsRef<RustyPipeQuery>>(
|
||||
paginator: Paginator<HistoryItem<TrackItem>>,
|
||||
query: Q,
|
||||
n_items: usize,
|
||||
) {
|
||||
let mut p = paginator;
|
||||
let query = query.as_ref();
|
||||
p.extend_limit(query, n_items).await.unwrap();
|
||||
assert_gte(p.items.len(), n_items, "items");
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_frameset(frameset: &Frameset) {
|
||||
assert_gte(frameset.frame_height, 20, "frame height");
|
||||
|
|
|
|||
Reference in a new issue