Skip to content

Commit

Permalink
fix: handle changes in invidious api
Browse files Browse the repository at this point in the history
  • Loading branch information
sarowish committed Feb 1, 2023
1 parent 0e8a123 commit d9ef233
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 51 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- No longer crashes if no tag is selected when trying to modify channels of a tag.
- Reload channels when a tag is deleted.
- Don't automatically create config directory unless generating instances file.
- Handle changes in Invidious API.

## [0.3.1] - 2022-10-16
### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Path to the configuration file can be specified with the `-c` flag.

database = "/home/username/.local/share/ytsub/videos.db" # Path to database file
instances = "/home/username/.config/ytsub/instances" # Path to instances file
tabs = ["videos"] # Tabs to fetch videos from [possible values: videos, shorts, streams]
tick_rate = 200 # Tick rate in milliseconds
request_timeout = 5 # Request timeout in seconds
highlight_symbol = "" # Symbol to highlight selected items
Expand Down
34 changes: 24 additions & 10 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,28 @@ impl Video {
impl From<&Value> for Video {
fn from(video_json: &Value) -> Self {
let is_upcoming = video_json.get("isUpcoming").unwrap().as_bool().unwrap();
let mut published = video_json.get("published").unwrap().as_u64().unwrap();
let mut length = video_json.get("lengthSeconds").unwrap().as_u64().unwrap();

if is_upcoming {
let premiere_timestamp = video_json
.get("premiereTimestamp")
.unwrap()
.as_u64()
.unwrap();

// In Invidious API, all shorts are marked as upcoming but the published key needs to be
// used for the release time. If the premiere timestamp is 0, assume it is a shorts.
if premiere_timestamp != 0 {
published = premiere_timestamp;
}
}

// In some Invidious instances length of shorts is 0
if length == 0 {
length = 60;
}

Video {
video_type: Default::default(),
video_id: video_json
Expand All @@ -123,17 +145,9 @@ impl From<&Value> for Video {
.as_str()
.unwrap()
.to_string(),
published: if is_upcoming {
video_json
.get("premiereTimestamp")
.unwrap()
.as_u64()
.unwrap()
} else {
video_json.get("published").unwrap().as_u64().unwrap()
},
published,
published_text: Default::default(),
length: Some(video_json.get("lengthSeconds").unwrap().as_u64().unwrap() as u32),
length: Some(length as u32),
watched: false,
new: true,
}
Expand Down
15 changes: 14 additions & 1 deletion src/config/options.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use crate::CLAP_ARGS;
use crate::{invidious::ChannelTab, CLAP_ARGS};
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Deserialize)]
pub struct UserOptions {
pub database: Option<PathBuf>,
pub instances: Option<PathBuf>,
pub tabs: Option<Vec<ChannelTab>>,
pub tick_rate: Option<u64>,
pub request_timeout: Option<u64>,
pub highlight_symbol: Option<String>,
Expand All @@ -16,6 +17,9 @@ pub struct UserOptions {
pub struct Options {
pub database: PathBuf,
pub instances: PathBuf,
pub videos_tab: bool,
pub shorts_tab: bool,
pub streams_tab: bool,
pub tick_rate: u64,
pub request_timeout: u64,
pub highlight_symbol: String,
Expand Down Expand Up @@ -56,6 +60,9 @@ impl Default for Options {
Options {
database: PathBuf::default(),
instances: PathBuf::default(),
videos_tab: true,
shorts_tab: false,
streams_tab: false,
tick_rate: 200,
request_timeout: 5,
highlight_symbol: String::new(),
Expand All @@ -77,6 +84,12 @@ impl From<UserOptions> for Options {
};
}

if let Some(tabs) = user_options.tabs {
options.videos_tab = tabs.contains(&ChannelTab::Videos);
options.shorts_tab = tabs.contains(&ChannelTab::Shorts);
options.streams_tab = tabs.contains(&ChannelTab::Streams);
}

set_options_field!(database);
set_options_field!(instances);
set_options_field!(tick_rate);
Expand Down
189 changes: 157 additions & 32 deletions src/invidious.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ use rand::prelude::*;
use rand::thread_rng;
use serde::Deserialize;
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use ureq::{Agent, AgentBuilder};

#[derive(Deserialize, PartialEq, Debug)]
#[serde(rename_all(deserialize = "lowercase"))]
pub enum ChannelTab {
Videos,
Shorts,
Streams,
}

#[derive(Deserialize)]
pub struct ChannelFeed {
#[serde(rename = "title")]
Expand All @@ -20,28 +30,34 @@ pub struct ChannelFeed {

impl From<Value> for ChannelFeed {
fn from(mut value: Value) -> Self {
let channel_title = value.get("author");
let mut channel_feed = Self {
channel_title: None,
channel_id: None,
videos: Vec::new(),
};

if let Some(channel_title) = channel_title {
Self {
channel_title: Some(channel_title.as_str().unwrap().to_string()),
channel_id: Some(value.get("authorId").unwrap().as_str().unwrap().to_string()),
videos: Video::vec_from_json(value["latestVideos"].take()),
}
let videos = if value["videos"].is_null() {
value
} else {
Self {
channel_title: None,
channel_id: None,
videos: Video::vec_from_json(value),
}
value["videos"].take()
};

if let Some(video) = videos.get(0) {
channel_feed.channel_title =
Some(video.get("author").unwrap().as_str().unwrap().to_string());

channel_feed.videos = Video::vec_from_json(videos);
}

channel_feed
}
}

#[derive(Clone)]
pub struct Instance {
pub domain: String,
agent: Agent,
old_version: Arc<AtomicBool>,
}

impl Instance {
Expand All @@ -51,38 +67,147 @@ impl Instance {
let agent = AgentBuilder::new()
.timeout(Duration::from_secs(OPTIONS.request_timeout))
.build();
Ok(Self { domain, agent })

Ok(Self {
domain,
agent,
old_version: Arc::new(AtomicBool::new(false)),
})
}

pub fn get_videos_of_channel(&self, channel_id: &str) -> Result<ChannelFeed> {
let url = format!("{}/api/v1/channels/{}", self.domain, channel_id);
Ok(ChannelFeed::from(self
pub fn get_videos_for_the_first_time(&mut self, channel_id: &str) -> Result<ChannelFeed> {
let mut channel_feed;
let url = format!("{}/api/v1/channels/{}/videos", self.domain, channel_id,);
let response = self
.agent
.get(&url)
.query(
"fields",
"author,authorId,latestVideos(title,videoId,published,lengthSeconds,isUpcoming,premiereTimestamp)",
if self.old_version.load(Ordering::SeqCst) {
"author,title,videoId,published,lengthSeconds,isUpcoming,premiereTimestamp"
}
else {
"videos(author,title,videoId,published,lengthSeconds,isUpcoming,premiereTimestamp)"
},
)
.call()?
.into_json::<Value>()?))
.call();

match response {
Ok(response) => channel_feed = ChannelFeed::from(response.into_json::<Value>()?),
Err(e) => {
// if the error code is 400, retry with the old api
if let ureq::Error::Status(400, _) = e {
self.old_version.store(true, Ordering::SeqCst);
return self.get_videos_for_the_first_time(channel_id);
} else {
return Err(anyhow::anyhow!(e));
}
}
}

channel_feed.channel_id = Some(channel_id.to_string());

if !self.old_version.load(Ordering::SeqCst) {
if !OPTIONS.videos_tab {
channel_feed.videos.drain(..);
}

if OPTIONS.shorts_tab {
if let Ok(videos) = self.get_tab_of_channel(channel_id, ChannelTab::Shorts) {
channel_feed.videos.extend(videos);
}
}

if OPTIONS.streams_tab {
if let Ok(videos) = self.get_tab_of_channel(channel_id, ChannelTab::Streams) {
channel_feed.videos.extend(videos);
}
}
}

Ok(channel_feed)
}

pub fn get_latest_videos_of_channel(&self, channel_id: &str) -> Result<ChannelFeed> {
let url = format!("{}/api/v1/channels/latest/{}", self.domain, channel_id);
let mut res = ChannelFeed::from(
self.agent
.get(&url)
.query(
"fields",
"title,videoId,published,lengthSeconds,isUpcoming,premiereTimestamp",
)
.call()?
.into_json::<Value>()?,
fn get_tab_of_channel(&self, channel_id: &str, tab: ChannelTab) -> Result<Vec<Video>> {
let url = format!(
"{}/api/v1/channels/{}/{}",
self.domain,
channel_id,
match tab {
ChannelTab::Videos => "",
ChannelTab::Shorts => "shorts",
ChannelTab::Streams => "streams",
}
);

res.channel_id = Some(channel_id.to_string());
let mut value = self
.agent
.get(&url)
.query(
"fields",
&format!(
"{}(title,videoId,published,lengthSeconds,isUpcoming,premiereTimestamp)",
match tab {
ChannelTab::Videos => "latestVideos",
_ => "videos",
},
),
)
.call()?
.into_json::<Value>()?;

let videos_array = match tab {
ChannelTab::Videos => value["latestVideos"].take(),
_ => value["videos"].take(),
};

if let Some(video) = videos_array.get(0) {
// if the key doesn't exist, assume that the tab is not available
if video.get("videoId").is_none() {
return Ok(Vec::new());
}
}

Ok(Video::vec_from_json(videos_array))
}

pub fn get_videos_of_channel(&mut self, channel_id: &str) -> Result<ChannelFeed> {
let mut channel_feed = ChannelFeed {
channel_title: None,
channel_id: Some(channel_id.to_string()),
videos: Vec::new(),
};

if OPTIONS.videos_tab {
if let Ok(videos) = self.get_tab_of_channel(channel_id, ChannelTab::Videos) {
channel_feed.videos.extend(videos);
}
}

let old_version = self.old_version.load(Ordering::SeqCst);

if OPTIONS.shorts_tab && !old_version {
match self.get_tab_of_channel(channel_id, ChannelTab::Shorts) {
Ok(videos) => channel_feed.videos.extend(videos),
Err(e) => {
// if the error code is 500 don't try to fetch shorts and streams tabs
if let Some(ureq::Error::Status(500, _)) = e.downcast_ref::<ureq::Error>() {
self.old_version.store(true, Ordering::SeqCst);
return self.get_videos_of_channel(channel_id);
} else {
return Err(anyhow::anyhow!(e));
}
}
}
}

if OPTIONS.streams_tab && !old_version {
if let Ok(videos) = self.get_tab_of_channel(channel_id, ChannelTab::Streams) {
channel_feed.videos.extend(videos);
}
}

Ok(res)
Ok(channel_feed)
}

pub fn get_rss_feed_of_channel(&self, channel_id: &str) -> Result<ChannelFeed> {
Expand Down
Loading

0 comments on commit d9ef233

Please sign in to comment.