Compare commits

...

1 commit

Author SHA1 Message Date
a72029ea54
feat: add timezone query option 2025-01-18 07:03:36 +01:00
14 changed files with 132 additions and 27 deletions

View file

@ -46,6 +46,7 @@ time = { version = "0.3.37", features = [
"serde-human-readable",
"serde-well-known",
] }
time-tz = { version = "2.0.0" }
futures-util = "0.3.31"
ress = "0.11.0"
phf = "0.11.0"

View file

@ -12,6 +12,7 @@ description = "CLI for RustyPipe - download videos and extract data from YouTube
[features]
default = ["native-tls"]
timezone = ["dep:time", "dep:time-tz"]
# Reqwest TLS options
native-tls = [
@ -49,6 +50,8 @@ futures-util.workspace = true
serde.workspace = true
serde_json.workspace = true
quick-xml.workspace = true
time = { workspace = true, optional = true }
time-tz = { workspace = true, optional = true }
indicatif.workspace = true
anyhow.workspace = true

View file

@ -54,6 +54,10 @@ struct Cli {
/// YouTube content country
#[clap(long, global = true)]
country: Option<String>,
/// UTC offset in minutes
#[cfg(feature = "timezone")]
#[clap(long, global = true)]
timezone: Option<String>,
/// Use authentication
#[clap(long, global = true)]
auth: bool,
@ -903,6 +907,18 @@ async fn run() -> anyhow::Result<()> {
if let Some(country) = cli.country {
rp = rp.country(Country::from_str(&country.to_ascii_uppercase()).expect("invalid country"));
}
#[cfg(feature = "timezone")]
if let Some(timezone) = cli.timezone {
use time::OffsetDateTime;
use time_tz::{Offset, TimeZone};
let tz = time_tz::timezones::get_by_name(&timezone).expect("invalid timezone");
let offset = tz
.get_offset_utc(&OffsetDateTime::now_utc())
.to_utc()
.whole_minutes();
rp = rp.timezone(tz.name(), offset);
}
if cli.auth {
rp = rp.authenticated();
}

View file

@ -220,6 +220,7 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel(
ctx.lang,
ctx.utc_offset,
&channel_data.c,
channel_data.warnings,
);
@ -265,6 +266,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel(
ctx.lang,
ctx.utc_offset,
&channel_data.c,
channel_data.warnings,
);
@ -280,7 +282,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
impl MapResponse<ChannelInfo> for response::ChannelAbout {
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<ChannelInfo>, ExtractionError> {
// Channel info is always fetched in English. There is no localized data there
// Channel info is always fetched in English. There is no localized data
// and it allows parsing the country name.
let lang = Language::En;
@ -335,7 +337,7 @@ impl MapResponse<ChannelInfo> for response::ChannelAbout {
.video_count_text
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
create_date: about.joined_date_text.and_then(|txt| {
timeago::parse_textual_date_or_warn(lang, &txt, &mut warnings)
timeago::parse_textual_date_or_warn(lang, ctx.utc_offset, &txt, &mut warnings)
.map(OffsetDateTime::date)
}),
view_count: about

View file

@ -177,7 +177,7 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
for item in items.c {
match item {
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
let mut mapper = YouTubeListMapper::<VideoItem>::new(ctx.lang);
let mut mapper = YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(contents);
mapper.conv_history_items(
header.map(|h| h.item_section_header_renderer.title),
@ -228,7 +228,7 @@ impl MapResponse<Paginator<VideoItem>> for response::History {
.section_list_renderer
.contents;
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(items);
Ok(MapResult {

View file

@ -34,7 +34,7 @@ use regex::Regex;
use reqwest::{header, Client, ClientBuilder, Request, RequestBuilder, Response, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha1::{Digest, Sha1};
use time::OffsetDateTime;
use time::{OffsetDateTime, UtcOffset};
use tokio::sync::RwLock as AsyncRwLock;
use crate::error::AuthError;
@ -380,6 +380,8 @@ struct RustyPipeRef {
struct RustyPipeOpts {
lang: Language,
country: Country,
timezone: Option<String>,
utc_offset_minutes: i16,
report: bool,
strict: bool,
auth: Option<bool>,
@ -495,6 +497,8 @@ impl Default for RustyPipeOpts {
Self {
lang: Language::En,
country: Country::Us,
timezone: None,
utc_offset_minutes: 0,
report: false,
strict: false,
auth: None,
@ -807,6 +811,21 @@ impl RustyPipeBuilder {
self
}
/// Set the timezone and its associated UTC offset in minutes used
/// when accessing the YouTube API.
///
/// This will also change the UTC offset of the returned dates.
///
/// **Default value**: `0` (UTC)
///
/// **Info**: you can set this option for individual queries, too
#[must_use]
pub fn timezone<S: Into<String>>(mut self, timezone: S, utc_offset_minutes: i16) -> Self {
self.default_opts.timezone = Some(timezone.into());
self.default_opts.utc_offset_minutes = utc_offset_minutes;
self
}
/// Generate a report on every operation.
///
/// This should only be used for debugging.
@ -1609,6 +1628,17 @@ impl RustyPipeQuery {
self
}
/// Set the timezone and its associated UTC offset in minutes used
/// when accessing the YouTube API.
///
/// This will also change the UTC offset of the returned dates.
#[must_use]
pub fn timezone<S: Into<String>>(mut self, timezone: S, utc_offset_minutes: i16) -> Self {
self.opts.timezone = Some(timezone.into());
self.opts.utc_offset_minutes = utc_offset_minutes;
self
}
/// Generate a report on every operation.
///
/// This should only be used for debugging.
@ -1763,6 +1793,8 @@ impl RustyPipeQuery {
} else {
(Language::En, Country::Us)
};
let utc_offset_minutes = self.opts.utc_offset_minutes;
let time_zone = self.opts.timezone.as_deref().unwrap_or("UTC");
match ctype {
ClientType::Desktop => YTContext {
@ -1774,6 +1806,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: Some(RequestYT::default()),
@ -1789,6 +1823,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: Some(RequestYT::default()),
@ -1804,6 +1840,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: Some(RequestYT::default()),
@ -1820,6 +1858,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: Some(RequestYT::default()),
@ -1839,6 +1879,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: None,
@ -1856,6 +1898,8 @@ impl RustyPipeQuery {
visitor_data,
hl,
gl,
time_zone,
utc_offset_minutes,
..Default::default()
},
request: None,
@ -2147,6 +2191,8 @@ impl RustyPipeQuery {
let ctx = MapRespCtx {
id,
lang: self.opts.lang,
utc_offset: UtcOffset::from_whole_seconds(i32::from(self.opts.utc_offset_minutes) * 60)
.map_err(|_| Error::Other("utc_offset overflow".into()))?,
deobf: ctx_src.deobf,
visitor_data: Some(&visitor_data),
client_type: ctype,
@ -2305,6 +2351,7 @@ impl AsRef<RustyPipeQuery> for RustyPipeQuery {
struct MapRespCtx<'a> {
id: &'a str,
lang: Language,
utc_offset: UtcOffset,
deobf: Option<&'a DeobfData>,
visitor_data: Option<&'a str>,
client_type: ClientType,
@ -2330,6 +2377,7 @@ impl<'a> MapRespCtx<'a> {
Self {
id,
lang: Language::En,
utc_offset: UtcOffset::UTC,
deobf: None,
visitor_data: None,
client_type: ClientType::Desktop,

View file

@ -127,7 +127,7 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
let estimated_results = self.estimated_results;
let items = continuation_items(self);
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(items);
Ok(MapResult {
@ -231,7 +231,8 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
for item in items.c {
match item {
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
let mut mapper =
response::YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(contents);
mapper.conv_history_items(
header.map(|h| h.item_section_header_renderer.title),

View file

@ -840,6 +840,7 @@ mod tests {
use path_macro::path;
use rstest::rstest;
use time::UtcOffset;
use super::*;
use crate::{deobfuscate::DeobfData, param::Language, util::tests::TESTFILES};
@ -871,6 +872,7 @@ mod tests {
.map_response(&MapRespCtx {
id: "pPvd8UxmSbQ",
lang: Language::En,
utc_offset: UtcOffset::UTC,
deobf: Some(&DEOBF_DATA),
visitor_data: None,
client_type,

View file

@ -90,7 +90,7 @@ impl MapResponse<Playlist> for response::Playlist {
.playlist_video_list_renderer
.contents;
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(video_items);
let (description, thumbnails, last_update_txt) = match self.sidebar {
@ -225,8 +225,13 @@ impl MapResponse<Playlist> for response::Playlist {
.as_deref()
.or(last_update_txt2.as_deref())
.and_then(|txt| {
timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut mapper.warnings)
.map(OffsetDateTime::date)
timeago::parse_textual_date_or_warn(
ctx.lang,
ctx.utc_offset,
txt,
&mut mapper.warnings,
)
.map(OffsetDateTime::date)
});
Ok(MapResult {

View file

@ -2,7 +2,7 @@ use serde::Deserialize;
use serde_with::{
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
};
use time::OffsetDateTime;
use time::{OffsetDateTime, UtcOffset};
use super::{
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
@ -461,6 +461,7 @@ impl IsShort for Vec<TimeOverlay> {
#[derive(Debug)]
pub(crate) struct YouTubeListMapper<T> {
lang: Language,
utc_offset: UtcOffset,
channel: Option<ChannelTag>,
pub items: Vec<T>,
@ -470,9 +471,10 @@ pub(crate) struct YouTubeListMapper<T> {
}
impl<T> YouTubeListMapper<T> {
pub fn new(lang: Language) -> Self {
pub fn new(lang: Language, utc_offset: UtcOffset) -> Self {
Self {
lang,
utc_offset,
channel: None,
items: Vec::new(),
warnings: Vec::new(),
@ -481,9 +483,15 @@ impl<T> YouTubeListMapper<T> {
}
}
pub fn with_channel<C>(lang: Language, channel: &Channel<C>, warnings: Vec<String>) -> Self {
pub fn with_channel<C>(
lang: Language,
utc_offset: UtcOffset,
channel: &Channel<C>,
warnings: Vec<String>,
) -> Self {
Self {
lang,
utc_offset,
channel: Some(ChannelTag {
id: channel.id.clone(),
name: channel.name.clone(),
@ -786,7 +794,12 @@ impl<T> YouTubeListMapper<T> {
thumbnail: tn.image.into(),
channel,
publish_date: publish_date_txt.as_deref().and_then(|t| {
timeago::parse_textual_date_or_warn(self.lang, t, &mut self.warnings)
timeago::parse_textual_date_or_warn(
self.lang,
self.utc_offset,
t,
&mut self.warnings,
)
}),
publish_date_txt,
view_count,

View file

@ -107,7 +107,7 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search {
.section_list_renderer
.contents;
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang);
let mut mapper = response::YouTubeListMapper::<YouTubeItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(items);
Ok(MapResult {

View file

@ -45,7 +45,7 @@ impl MapResponse<Vec<VideoItem>> for response::Trending {
.section_list_renderer
.contents;
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(items);
Ok(MapResult {

View file

@ -180,7 +180,12 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
// so we ignore parse errors here for now
like_text.and_then(|txt| util::parse_numeric(&txt).ok()),
date_text.as_deref().and_then(|txt| {
timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut warnings)
timeago::parse_textual_date_or_warn(
ctx.lang,
ctx.utc_offset,
txt,
&mut warnings,
)
}),
date_text,
view_count
@ -277,7 +282,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
r,
sr.secondary_results.continuations,
visitor_data.clone(),
ctx.lang,
ctx,
);
warnings.append(&mut res.warnings);
res.c
@ -468,9 +473,9 @@ fn map_recommendations(
r: MapResult<Vec<response::YouTubeListItem>>,
continuations: Option<Vec<response::MusicContinuationData>>,
visitor_data: Option<String>,
lang: Language,
ctx: &MapRespCtx<'_>,
) -> MapResult<Paginator<VideoItem>> {
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang, ctx.utc_offset);
mapper.map_response(r);
mapper.ctoken = mapper.ctoken.or_else(|| {

View file

@ -13,7 +13,7 @@
use std::ops::Mul;
use serde::{Deserialize, Serialize};
use time::{Date, Duration, Month, OffsetDateTime};
use time::{Date, Duration, Month, OffsetDateTime, UtcOffset};
use crate::{
param::Language,
@ -333,8 +333,13 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDa
/// 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)
pub fn parse_textual_date_to_dt(
lang: Language,
utc_offset: UtcOffset,
textual_date: &str,
) -> Option<OffsetDateTime> {
parse_textual_date(lang, textual_date)
.map(|parsed| OffsetDateTime::from(parsed).replace_offset(utc_offset))
}
/// Parse a textual date (e.g. "29 minutes ago" "Jul 2, 2014") into a Date object.
@ -345,15 +350,17 @@ pub fn parse_textual_date_to_d(
textual_date: &str,
warnings: &mut Vec<String>,
) -> Option<Date> {
parse_textual_date_or_warn(lang, textual_date, warnings).map(OffsetDateTime::date)
parse_textual_date_or_warn(lang, UtcOffset::UTC, textual_date, warnings)
.map(OffsetDateTime::date)
}
pub fn parse_textual_date_or_warn(
lang: Language,
utc_offset: UtcOffset,
textual_date: &str,
warnings: &mut Vec<String>,
) -> Option<OffsetDateTime> {
let res = parse_textual_date_to_dt(lang, textual_date);
let res = parse_textual_date_to_dt(lang, utc_offset, textual_date);
if res.is_none() {
warnings.push(format!("could not parse textual date `{textual_date}`"));
}
@ -1101,11 +1108,13 @@ mod tests {
#[test]
fn t_to_datetime() {
// Absolute date
let date = parse_textual_date_to_dt(Language::En, "Last updated on Jan 3, 2020").unwrap();
let date =
parse_textual_date_to_dt(Language::En, UtcOffset::UTC, "Last updated on Jan 3, 2020")
.unwrap();
assert_eq!(date, datetime!(2020-1-3 0:00 +0));
// Relative date
let date = parse_textual_date_to_dt(Language::En, "1 year ago").unwrap();
let date = parse_textual_date_to_dt(Language::En, UtcOffset::UTC, "1 year ago").unwrap();
let now = OffsetDateTime::now_utc();
assert_eq!(date.year(), now.year() - 1);
}