Compare commits
1 commit
main
...
feat/timez
Author | SHA1 | Date | |
---|---|---|---|
a72029ea54 |
18 changed files with 159 additions and 67 deletions
|
@ -46,6 +46,7 @@ time = { version = "0.3.37", features = [
|
||||||
"serde-human-readable",
|
"serde-human-readable",
|
||||||
"serde-well-known",
|
"serde-well-known",
|
||||||
] }
|
] }
|
||||||
|
time-tz = { version = "2.0.0" }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
ress = "0.11.0"
|
ress = "0.11.0"
|
||||||
phf = "0.11.0"
|
phf = "0.11.0"
|
||||||
|
|
|
@ -12,6 +12,7 @@ description = "CLI for RustyPipe - download videos and extract data from YouTube
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["native-tls"]
|
default = ["native-tls"]
|
||||||
|
timezone = ["dep:time", "dep:time-tz"]
|
||||||
|
|
||||||
# Reqwest TLS options
|
# Reqwest TLS options
|
||||||
native-tls = [
|
native-tls = [
|
||||||
|
@ -49,6 +50,8 @@ futures-util.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
quick-xml.workspace = true
|
quick-xml.workspace = true
|
||||||
|
time = { workspace = true, optional = true }
|
||||||
|
time-tz = { workspace = true, optional = true }
|
||||||
|
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
|
|
@ -54,6 +54,10 @@ struct Cli {
|
||||||
/// YouTube content country
|
/// YouTube content country
|
||||||
#[clap(long, global = true)]
|
#[clap(long, global = true)]
|
||||||
country: Option<String>,
|
country: Option<String>,
|
||||||
|
/// UTC offset in minutes
|
||||||
|
#[cfg(feature = "timezone")]
|
||||||
|
#[clap(long, global = true)]
|
||||||
|
timezone: Option<String>,
|
||||||
/// Use authentication
|
/// Use authentication
|
||||||
#[clap(long, global = true)]
|
#[clap(long, global = true)]
|
||||||
auth: bool,
|
auth: bool,
|
||||||
|
@ -903,6 +907,18 @@ 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"));
|
||||||
}
|
}
|
||||||
|
#[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 {
|
if cli.auth {
|
||||||
rp = rp.authenticated();
|
rp = rp.authenticated();
|
||||||
}
|
}
|
||||||
|
|
|
@ -220,6 +220,7 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
|
||||||
|
|
||||||
let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel(
|
let mut mapper = response::YouTubeListMapper::<VideoItem>::with_channel(
|
||||||
ctx.lang,
|
ctx.lang,
|
||||||
|
ctx.utc_offset,
|
||||||
&channel_data.c,
|
&channel_data.c,
|
||||||
channel_data.warnings,
|
channel_data.warnings,
|
||||||
);
|
);
|
||||||
|
@ -265,6 +266,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
|
||||||
|
|
||||||
let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel(
|
let mut mapper = response::YouTubeListMapper::<PlaylistItem>::with_channel(
|
||||||
ctx.lang,
|
ctx.lang,
|
||||||
|
ctx.utc_offset,
|
||||||
&channel_data.c,
|
&channel_data.c,
|
||||||
channel_data.warnings,
|
channel_data.warnings,
|
||||||
);
|
);
|
||||||
|
@ -280,7 +282,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
|
||||||
|
|
||||||
impl MapResponse<ChannelInfo> for response::ChannelAbout {
|
impl MapResponse<ChannelInfo> for response::ChannelAbout {
|
||||||
fn map_response(self, ctx: &MapRespCtx<'_>) -> Result<MapResult<ChannelInfo>, ExtractionError> {
|
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.
|
// and it allows parsing the country name.
|
||||||
let lang = Language::En;
|
let lang = Language::En;
|
||||||
|
|
||||||
|
@ -335,7 +337,7 @@ impl MapResponse<ChannelInfo> for response::ChannelAbout {
|
||||||
.video_count_text
|
.video_count_text
|
||||||
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
|
.and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)),
|
||||||
create_date: about.joined_date_text.and_then(|txt| {
|
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)
|
.map(OffsetDateTime::date)
|
||||||
}),
|
}),
|
||||||
view_count: about
|
view_count: about
|
||||||
|
|
|
@ -177,7 +177,7 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::History {
|
||||||
for item in items.c {
|
for item in items.c {
|
||||||
match item {
|
match item {
|
||||||
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
|
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.map_response(contents);
|
||||||
mapper.conv_history_items(
|
mapper.conv_history_items(
|
||||||
header.map(|h| h.item_section_header_renderer.title),
|
header.map(|h| h.item_section_header_renderer.title),
|
||||||
|
@ -228,7 +228,7 @@ impl MapResponse<Paginator<VideoItem>> for response::History {
|
||||||
.section_list_renderer
|
.section_list_renderer
|
||||||
.contents;
|
.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);
|
mapper.map_response(items);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
|
|
|
@ -34,7 +34,7 @@ use regex::Regex;
|
||||||
use reqwest::{header, Client, ClientBuilder, Request, RequestBuilder, Response, StatusCode};
|
use reqwest::{header, Client, ClientBuilder, Request, RequestBuilder, Response, StatusCode};
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use sha1::{Digest, Sha1};
|
use sha1::{Digest, Sha1};
|
||||||
use time::OffsetDateTime;
|
use time::{OffsetDateTime, UtcOffset};
|
||||||
use tokio::sync::RwLock as AsyncRwLock;
|
use tokio::sync::RwLock as AsyncRwLock;
|
||||||
|
|
||||||
use crate::error::AuthError;
|
use crate::error::AuthError;
|
||||||
|
@ -380,6 +380,8 @@ struct RustyPipeRef {
|
||||||
struct RustyPipeOpts {
|
struct RustyPipeOpts {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
country: Country,
|
country: Country,
|
||||||
|
timezone: Option<String>,
|
||||||
|
utc_offset_minutes: i16,
|
||||||
report: bool,
|
report: bool,
|
||||||
strict: bool,
|
strict: bool,
|
||||||
auth: Option<bool>,
|
auth: Option<bool>,
|
||||||
|
@ -495,6 +497,8 @@ impl Default for RustyPipeOpts {
|
||||||
Self {
|
Self {
|
||||||
lang: Language::En,
|
lang: Language::En,
|
||||||
country: Country::Us,
|
country: Country::Us,
|
||||||
|
timezone: None,
|
||||||
|
utc_offset_minutes: 0,
|
||||||
report: false,
|
report: false,
|
||||||
strict: false,
|
strict: false,
|
||||||
auth: None,
|
auth: None,
|
||||||
|
@ -807,6 +811,21 @@ impl RustyPipeBuilder {
|
||||||
self
|
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.
|
/// Generate a report on every operation.
|
||||||
///
|
///
|
||||||
/// This should only be used for debugging.
|
/// This should only be used for debugging.
|
||||||
|
@ -1609,6 +1628,17 @@ impl RustyPipeQuery {
|
||||||
self
|
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.
|
/// Generate a report on every operation.
|
||||||
///
|
///
|
||||||
/// This should only be used for debugging.
|
/// This should only be used for debugging.
|
||||||
|
@ -1763,6 +1793,8 @@ impl RustyPipeQuery {
|
||||||
} else {
|
} else {
|
||||||
(Language::En, Country::Us)
|
(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 {
|
match ctype {
|
||||||
ClientType::Desktop => YTContext {
|
ClientType::Desktop => YTContext {
|
||||||
|
@ -1774,6 +1806,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
@ -1789,6 +1823,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
@ -1804,6 +1840,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
@ -1820,6 +1858,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
@ -1839,6 +1879,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: None,
|
request: None,
|
||||||
|
@ -1856,6 +1898,8 @@ impl RustyPipeQuery {
|
||||||
visitor_data,
|
visitor_data,
|
||||||
hl,
|
hl,
|
||||||
gl,
|
gl,
|
||||||
|
time_zone,
|
||||||
|
utc_offset_minutes,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
request: None,
|
request: None,
|
||||||
|
@ -2147,6 +2191,8 @@ impl RustyPipeQuery {
|
||||||
let ctx = MapRespCtx {
|
let ctx = MapRespCtx {
|
||||||
id,
|
id,
|
||||||
lang: self.opts.lang,
|
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,
|
deobf: ctx_src.deobf,
|
||||||
visitor_data: Some(&visitor_data),
|
visitor_data: Some(&visitor_data),
|
||||||
client_type: ctype,
|
client_type: ctype,
|
||||||
|
@ -2305,6 +2351,7 @@ impl AsRef<RustyPipeQuery> for RustyPipeQuery {
|
||||||
struct MapRespCtx<'a> {
|
struct MapRespCtx<'a> {
|
||||||
id: &'a str,
|
id: &'a str,
|
||||||
lang: Language,
|
lang: Language,
|
||||||
|
utc_offset: UtcOffset,
|
||||||
deobf: Option<&'a DeobfData>,
|
deobf: Option<&'a DeobfData>,
|
||||||
visitor_data: Option<&'a str>,
|
visitor_data: Option<&'a str>,
|
||||||
client_type: ClientType,
|
client_type: ClientType,
|
||||||
|
@ -2330,6 +2377,7 @@ impl<'a> MapRespCtx<'a> {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
lang: Language::En,
|
lang: Language::En,
|
||||||
|
utc_offset: UtcOffset::UTC,
|
||||||
deobf: None,
|
deobf: None,
|
||||||
visitor_data: None,
|
visitor_data: None,
|
||||||
client_type: ClientType::Desktop,
|
client_type: ClientType::Desktop,
|
||||||
|
|
|
@ -127,7 +127,7 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
|
||||||
let estimated_results = self.estimated_results;
|
let estimated_results = self.estimated_results;
|
||||||
let items = continuation_items(self);
|
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);
|
mapper.map_response(items);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
|
@ -231,7 +231,8 @@ impl MapResponse<Paginator<HistoryItem<VideoItem>>> for response::Continuation {
|
||||||
for item in items.c {
|
for item in items.c {
|
||||||
match item {
|
match item {
|
||||||
response::YouTubeListItem::ItemSectionRenderer { header, contents } => {
|
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.map_response(contents);
|
||||||
mapper.conv_history_items(
|
mapper.conv_history_items(
|
||||||
header.map(|h| h.item_section_header_renderer.title),
|
header.map(|h| h.item_section_header_renderer.title),
|
||||||
|
|
|
@ -680,11 +680,7 @@ impl StreamsMapper {
|
||||||
itag: f.itag,
|
itag: f.itag,
|
||||||
bitrate: f.bitrate,
|
bitrate: f.bitrate,
|
||||||
average_bitrate: f.average_bitrate.unwrap_or(f.bitrate),
|
average_bitrate: f.average_bitrate.unwrap_or(f.bitrate),
|
||||||
size: f.content_length.ok_or_else(|| {
|
size: f.content_length.unwrap(),
|
||||||
ExtractionError::InvalidData(
|
|
||||||
format!("no audio content length. itag: {}", f.itag).into(),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
index_range: f.index_range,
|
index_range: f.index_range,
|
||||||
init_range: f.init_range,
|
init_range: f.init_range,
|
||||||
duration_ms: f.approx_duration_ms,
|
duration_ms: f.approx_duration_ms,
|
||||||
|
@ -844,6 +840,7 @@ mod tests {
|
||||||
|
|
||||||
use path_macro::path;
|
use path_macro::path;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
use time::UtcOffset;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{deobfuscate::DeobfData, param::Language, util::tests::TESTFILES};
|
use crate::{deobfuscate::DeobfData, param::Language, util::tests::TESTFILES};
|
||||||
|
@ -875,6 +872,7 @@ mod tests {
|
||||||
.map_response(&MapRespCtx {
|
.map_response(&MapRespCtx {
|
||||||
id: "pPvd8UxmSbQ",
|
id: "pPvd8UxmSbQ",
|
||||||
lang: Language::En,
|
lang: Language::En,
|
||||||
|
utc_offset: UtcOffset::UTC,
|
||||||
deobf: Some(&DEOBF_DATA),
|
deobf: Some(&DEOBF_DATA),
|
||||||
visitor_data: None,
|
visitor_data: None,
|
||||||
client_type,
|
client_type,
|
||||||
|
|
|
@ -90,7 +90,7 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
.playlist_video_list_renderer
|
.playlist_video_list_renderer
|
||||||
.contents;
|
.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);
|
mapper.map_response(video_items);
|
||||||
|
|
||||||
let (description, thumbnails, last_update_txt) = match self.sidebar {
|
let (description, thumbnails, last_update_txt) = match self.sidebar {
|
||||||
|
@ -225,8 +225,13 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(last_update_txt2.as_deref())
|
.or(last_update_txt2.as_deref())
|
||||||
.and_then(|txt| {
|
.and_then(|txt| {
|
||||||
timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut mapper.warnings)
|
timeago::parse_textual_date_or_warn(
|
||||||
.map(OffsetDateTime::date)
|
ctx.lang,
|
||||||
|
ctx.utc_offset,
|
||||||
|
txt,
|
||||||
|
&mut mapper.warnings,
|
||||||
|
)
|
||||||
|
.map(OffsetDateTime::date)
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
|
|
|
@ -150,7 +150,9 @@ pub(crate) struct Format {
|
||||||
|
|
||||||
impl Format {
|
impl Format {
|
||||||
pub fn is_audio(&self) -> bool {
|
pub fn is_audio(&self) -> bool {
|
||||||
self.audio_quality.is_some() && self.audio_sample_rate.is_some()
|
self.content_length.is_some()
|
||||||
|
&& self.audio_quality.is_some()
|
||||||
|
&& self.audio_sample_rate.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_video(&self) -> bool {
|
pub fn is_video(&self) -> bool {
|
||||||
|
|
|
@ -2,7 +2,7 @@ use serde::Deserialize;
|
||||||
use serde_with::{
|
use serde_with::{
|
||||||
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
|
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::{OffsetDateTime, UtcOffset};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
|
ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer,
|
||||||
|
@ -461,6 +461,7 @@ impl IsShort for Vec<TimeOverlay> {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct YouTubeListMapper<T> {
|
pub(crate) struct YouTubeListMapper<T> {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
|
utc_offset: UtcOffset,
|
||||||
channel: Option<ChannelTag>,
|
channel: Option<ChannelTag>,
|
||||||
|
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
|
@ -470,9 +471,10 @@ pub(crate) struct YouTubeListMapper<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> YouTubeListMapper<T> {
|
impl<T> YouTubeListMapper<T> {
|
||||||
pub fn new(lang: Language) -> Self {
|
pub fn new(lang: Language, utc_offset: UtcOffset) -> Self {
|
||||||
Self {
|
Self {
|
||||||
lang,
|
lang,
|
||||||
|
utc_offset,
|
||||||
channel: None,
|
channel: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
warnings: 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 {
|
Self {
|
||||||
lang,
|
lang,
|
||||||
|
utc_offset,
|
||||||
channel: Some(ChannelTag {
|
channel: Some(ChannelTag {
|
||||||
id: channel.id.clone(),
|
id: channel.id.clone(),
|
||||||
name: channel.name.clone(),
|
name: channel.name.clone(),
|
||||||
|
@ -786,7 +794,12 @@ impl<T> YouTubeListMapper<T> {
|
||||||
thumbnail: tn.image.into(),
|
thumbnail: tn.image.into(),
|
||||||
channel,
|
channel,
|
||||||
publish_date: publish_date_txt.as_deref().and_then(|t| {
|
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,
|
publish_date_txt,
|
||||||
view_count,
|
view_count,
|
||||||
|
|
|
@ -107,7 +107,7 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search {
|
||||||
.section_list_renderer
|
.section_list_renderer
|
||||||
.contents;
|
.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);
|
mapper.map_response(items);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
|
|
|
@ -100,26 +100,6 @@ VideoPlayer(
|
||||||
drm_track_type: None,
|
drm_track_type: None,
|
||||||
drm_systems: [],
|
drm_systems: [],
|
||||||
),
|
),
|
||||||
VideoStream(
|
|
||||||
url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&dur=163.096&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=22&lmt=1580005750956837&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFlQZgR63Yz9UgY9gVqiyGDVkZmSmACRP3-MmKN7CRzQCIAMHAwZbHmWL1qNH4Nu3A0pXZwErXMVPzMIt-PyxeZqa&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cratebypass%2Cdur%2Clmt&txp=2211222&vprv=1",
|
|
||||||
itag: 22,
|
|
||||||
bitrate: 1574434,
|
|
||||||
average_bitrate: 1574434,
|
|
||||||
size: None,
|
|
||||||
index_range: None,
|
|
||||||
init_range: None,
|
|
||||||
duration_ms: Some(163096),
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
fps: 30,
|
|
||||||
quality: "720p",
|
|
||||||
hdr: false,
|
|
||||||
mime: "video/mp4; codecs=\"avc1.64001F, mp4a.40.2\"",
|
|
||||||
format: mp4,
|
|
||||||
codec: avc1,
|
|
||||||
drm_track_type: None,
|
|
||||||
drm_systems: [],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
video_only_streams: [
|
video_only_streams: [
|
||||||
VideoStream(
|
VideoStream(
|
||||||
|
@ -252,6 +232,26 @@ VideoPlayer(
|
||||||
drm_track_type: None,
|
drm_track_type: None,
|
||||||
drm_systems: [],
|
drm_systems: [],
|
||||||
),
|
),
|
||||||
|
VideoStream(
|
||||||
|
url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&dur=163.096&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=22&lmt=1580005750956837&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&pl=37&ratebypass=yes&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgFlQZgR63Yz9UgY9gVqiyGDVkZmSmACRP3-MmKN7CRzQCIAMHAwZbHmWL1qNH4Nu3A0pXZwErXMVPzMIt-PyxeZqa&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cratebypass%2Cdur%2Clmt&txp=2211222&vprv=1",
|
||||||
|
itag: 22,
|
||||||
|
bitrate: 1574434,
|
||||||
|
average_bitrate: 1574434,
|
||||||
|
size: None,
|
||||||
|
index_range: None,
|
||||||
|
init_range: None,
|
||||||
|
duration_ms: Some(163096),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fps: 30,
|
||||||
|
quality: "720p",
|
||||||
|
hdr: false,
|
||||||
|
mime: "video/mp4; codecs=\"avc1.64001F, mp4a.40.2\"",
|
||||||
|
format: mp4,
|
||||||
|
codec: avc1,
|
||||||
|
drm_track_type: None,
|
||||||
|
drm_systems: [],
|
||||||
|
),
|
||||||
VideoStream(
|
VideoStream(
|
||||||
url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=22365208&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgR6KqCOoig_FMl2tWKa7qHSmCjIZa9S7ABzEI16qdO2sCIFXccwql4bqV9CHlqXY4tgxyMFUsp7vW4XUjxs3AyG6H&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1",
|
url: "https://rr5---sn-h0jeenek.googlevideo.com/videoplayback?c=ANDROID&clen=22365208&dur=163.046&ei=q1jpYtOPEYSBgQeHmqbwAQ&expire=1659481355&fexp=24001373%2C24007246&fvip=4&gir=yes&id=o-AEDMTCojVtwpIKOdhBaxEHE5s322qnAJHGqa2r1F46BM&initcwndbps=1527500&ip=2003%3Ade%3Aaf0e%3A2f00%3Ade47%3A297%3Aa6db%3A774e&itag=398&keepalive=yes&lmt=1608048380553749&lsig=AG3C_xAwRgIhAOiL-qJ04sA8FSOkEJfOYl3gFe4SzwYu_rAf3DMLHYigAiEA0Upi1HqqIu7NH_LTDL0jT1R5TTozQypL5FiSP9RoqtU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=mQ&mime=video%2Fmp4&mm=31%2C29&mn=sn-h0jeenek%2Csn-h0jelnez&ms=au%2Crdu&mt=1659459429&mv=m&mvi=5&otfp=1&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRAIgR6KqCOoig_FMl2tWKa7qHSmCjIZa9S7ABzEI16qdO2sCIFXccwql4bqV9CHlqXY4tgxyMFUsp7vW4XUjxs3AyG6H&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cgir%2Cclen%2Cotfp%2Cdur%2Clmt&txp=1311222&vprv=1",
|
||||||
itag: 398,
|
itag: 398,
|
||||||
|
|
|
@ -45,7 +45,7 @@ impl MapResponse<Vec<VideoItem>> for response::Trending {
|
||||||
.section_list_renderer
|
.section_list_renderer
|
||||||
.contents;
|
.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);
|
mapper.map_response(items);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
|
|
|
@ -180,7 +180,12 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||||
// so we ignore parse errors here for now
|
// so we ignore parse errors here for now
|
||||||
like_text.and_then(|txt| util::parse_numeric(&txt).ok()),
|
like_text.and_then(|txt| util::parse_numeric(&txt).ok()),
|
||||||
date_text.as_deref().and_then(|txt| {
|
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,
|
date_text,
|
||||||
view_count
|
view_count
|
||||||
|
@ -277,7 +282,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||||
r,
|
r,
|
||||||
sr.secondary_results.continuations,
|
sr.secondary_results.continuations,
|
||||||
visitor_data.clone(),
|
visitor_data.clone(),
|
||||||
ctx.lang,
|
ctx,
|
||||||
);
|
);
|
||||||
warnings.append(&mut res.warnings);
|
warnings.append(&mut res.warnings);
|
||||||
res.c
|
res.c
|
||||||
|
@ -468,9 +473,9 @@ fn map_recommendations(
|
||||||
r: MapResult<Vec<response::YouTubeListItem>>,
|
r: MapResult<Vec<response::YouTubeListItem>>,
|
||||||
continuations: Option<Vec<response::MusicContinuationData>>,
|
continuations: Option<Vec<response::MusicContinuationData>>,
|
||||||
visitor_data: Option<String>,
|
visitor_data: Option<String>,
|
||||||
lang: Language,
|
ctx: &MapRespCtx<'_>,
|
||||||
) -> MapResult<Paginator<VideoItem>> {
|
) -> 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.map_response(r);
|
||||||
|
|
||||||
mapper.ctoken = mapper.ctoken.or_else(|| {
|
mapper.ctoken = mapper.ctoken.or_else(|| {
|
||||||
|
|
|
@ -148,20 +148,6 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn t_protobuilder() {
|
|
||||||
let mut pb = ProtoBuilder::new();
|
|
||||||
pb.varint(1, 128);
|
|
||||||
pb.varint(2, 1234567890);
|
|
||||||
pb.varint(3, 1234567890123456789);
|
|
||||||
pb.string(4, "Hello");
|
|
||||||
pb.bytes(5, &[1, 2, 3]);
|
|
||||||
assert_eq!(
|
|
||||||
pb.to_base64(),
|
|
||||||
"CIABENKF2MwEGJWCpu_HnoSRESIFSGVsbG8qAwECAw%3D%3D"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn t_parse_proto() {
|
fn t_parse_proto() {
|
||||||
let p = "GhhVQzl2cnZOU0wzeGNXR1NrVjg2UkVCU2c%3D";
|
let p = "GhhVQzl2cnZOU0wzeGNXR1NrVjg2UkVCU2c%3D";
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
use std::ops::Mul;
|
use std::ops::Mul;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::{Date, Duration, Month, OffsetDateTime};
|
use time::{Date, Duration, Month, OffsetDateTime, UtcOffset};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
param::Language,
|
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.
|
/// 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(
|
||||||
parse_textual_date(lang, textual_date).map(OffsetDateTime::from)
|
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.
|
/// 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,
|
textual_date: &str,
|
||||||
warnings: &mut Vec<String>,
|
warnings: &mut Vec<String>,
|
||||||
) -> Option<Date> {
|
) -> 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(
|
pub fn parse_textual_date_or_warn(
|
||||||
lang: Language,
|
lang: Language,
|
||||||
|
utc_offset: UtcOffset,
|
||||||
textual_date: &str,
|
textual_date: &str,
|
||||||
warnings: &mut Vec<String>,
|
warnings: &mut Vec<String>,
|
||||||
) -> Option<OffsetDateTime> {
|
) -> 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() {
|
if res.is_none() {
|
||||||
warnings.push(format!("could not parse textual date `{textual_date}`"));
|
warnings.push(format!("could not parse textual date `{textual_date}`"));
|
||||||
}
|
}
|
||||||
|
@ -1101,11 +1108,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn t_to_datetime() {
|
fn t_to_datetime() {
|
||||||
// Absolute date
|
// 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));
|
assert_eq!(date, datetime!(2020-1-3 0:00 +0));
|
||||||
|
|
||||||
// Relative date
|
// 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();
|
let now = OffsetDateTime::now_utc();
|
||||||
assert_eq!(date.year(), now.year() - 1);
|
assert_eq!(date.year(), now.year() - 1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -887,8 +887,11 @@ async fn channel_videos(rp: RustyPipe) {
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn channel_shorts(rp: RustyPipe) {
|
async fn channel_shorts(rp: RustyPipe) {
|
||||||
|
let vd = rp.query().get_visitor_data().await.unwrap();
|
||||||
|
|
||||||
let channel = rp
|
let channel = rp
|
||||||
.query()
|
.query()
|
||||||
|
.visitor_data(vd)
|
||||||
.channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts)
|
.channel_videos_tab("UCh8gHdtzO2tXd593_bjErWg", ChannelVideoTab::Shorts)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
Loading…
Reference in a new issue