diff --git a/.gitignore b/.gitignore index 079be4b..f64cdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /pkg /.DS_Store /.vscode +/jftest diff --git a/Cargo.lock b/Cargo.lock index fb7ffea..ee13ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,17 +65,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "async-recursion" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30c5ef0ede93efbf733c1a727f3b6b5a1060bbedd5600183e66f6e4be4af0ec5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -103,12 +92,6 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bumpalo" version = "3.14.0" @@ -221,9 +204,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -235,6 +218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -243,6 +227,18 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + [[package]] name = "futures-task" version = "0.3.29" @@ -256,9 +252,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -388,9 +388,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -410,49 +410,31 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "jellyfin-rpc" -version = "1.1.0" -dependencies = [ - "async-recursion", - "colored", - "discord-rich-presence", - "log", - "reqwest", - "retry", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "jellyfin-rpc" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6afec3457b4628803269a56084e23701f4af7beecebde3dd74ede3888f9e51" +version = "1.2.0" dependencies = [ - "async-recursion", - "colored", "discord-rich-presence", "log", "reqwest", "retry", "serde", "serde_json", - "tokio", + "url", ] [[package]] name = "jellyfin-rpc-cli" -version = "1.1.0" +version = "1.2.0" dependencies = [ "clap", "colored", - "jellyfin-rpc 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "jellyfin-rpc", "log", "reqwest", "retry", + "serde", + "serde_json", "simple_logger", "time", - "tokio", ] [[package]] @@ -476,16 +458,6 @@ version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" -[[package]] -name = "lock_api" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.20" @@ -564,34 +536,11 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" @@ -685,15 +634,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags", -] - [[package]] name = "reqwest" version = "0.12.4" @@ -702,6 +642,7 @@ checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -811,12 +752,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.190" @@ -860,20 +795,11 @@ dependencies = [ "serde", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] - [[package]] name = "simple_logger" -version = "4.3.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e7e46c8c90251d47d08b28b8a419ffb4aede0f87c2eea95e17d1d5bacbf3ef1" +checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" dependencies = [ "colored", "log", @@ -881,6 +807,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -991,25 +926,11 @@ dependencies = [ "libc", "mio", "num_cpus", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys", ] -[[package]] -name = "tokio-macros" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio-rustls" version = "0.25.0" @@ -1104,9 +1025,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.4.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 025827c..6d299ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "jellyfin-rpc", "jellyfin-rpc-cli", + #"jftest", # Used for local testing ] default-members = [ diff --git a/example.json b/example.json deleted file mode 120000 index 6d9bbd5..0000000 --- a/example.json +++ /dev/null @@ -1 +0,0 @@ -./jellyfin-rpc/example.json \ No newline at end of file diff --git a/example.json b/example.json new file mode 100644 index 0000000..4b9f9e3 --- /dev/null +++ b/example.json @@ -0,0 +1,41 @@ +{ + "jellyfin": { + "url": "https://example.com", + "api_key": "sadasodsapasdskd", + "username": "your_username_here", + "music": { + "display": "genres", + "separator": "-" + }, + "self_signed_cert": false, + "show_simple": false, + "append_prefix": false, + "add_divider": false, + "_comment": "the 4 lines below and this line arent needed and should be removed, by default nothing will display if these are present", + "blacklist": { + "media_types": ["music", "movie", "episode", "livetv"], + "libraries": ["Anime", "Anime Movies"] + } + }, + "discord": { + "application_id": "1053747938519679018", + "buttons": [ + { + "name": "dynamic", + "url": "dynamic" + }, + { + "name": "dynamic", + "url": "dynamic" + } + ], + "show_paused": true + }, + "imgur": { + "client_id": "asdjdjdg394209fdjs093" + }, + "images": { + "enable_images": true, + "imgur_images": true + } +} diff --git a/jellyfin-rpc-cli/Cargo.toml b/jellyfin-rpc-cli/Cargo.toml index 1c460d1..49f2ef5 100644 --- a/jellyfin-rpc-cli/Cargo.toml +++ b/jellyfin-rpc-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jellyfin-rpc-cli" -version = "1.1.0" +version = "1.2.0" edition = "2021" description = "Displays the content you're currently watching on Discord!" license = "GPL-3.0-or-later" @@ -24,28 +24,28 @@ updates = ["dep:reqwest"] colored = "2.1" retry = "2.0" log = "0.4" -simple_logger = "4.3" +simple_logger = "5.0" time = "0.3" +serde_json = "1.0" [dependencies.jellyfin-rpc] -features = ["imgur"] -version = "1.1.0" -#path = "../jellyfin-rpc" +#version = "1.1.0" +path = "../jellyfin-rpc" [dependencies.clap] features = ["derive"] version = "4.5" -[dependencies.tokio] -features = ["full"] -version = "1" - [dependencies.reqwest] default-features = false -features = ["rustls-tls", "json"] +features = ["rustls-tls", "json", "blocking"] version = "0.12" optional = true +[dependencies.serde] +features = ["derive"] +version = "1.0" + [profile.release] strip = true lto = true diff --git a/jellyfin-rpc-cli/src/config.rs b/jellyfin-rpc-cli/src/config.rs new file mode 100644 index 0000000..ed4a92b --- /dev/null +++ b/jellyfin-rpc-cli/src/config.rs @@ -0,0 +1,361 @@ +use jellyfin_rpc::{MediaType, Button}; +use log::debug; +use serde::{Deserialize, Serialize}; +use serde_json; +use std::env; + +pub struct Config { + pub jellyfin: Jellyfin, + pub discord: Discord, + pub imgur: Imgur, + pub images: Images, +} + +pub struct Jellyfin { + /// URL to the jellyfin server. + pub url: String, + /// Api key from the jellyfin server, used to gather what's being watched. + pub api_key: String, + /// Username of the person that info should be gathered from. + pub username: Vec, + /// Contains configuration for Music display. + pub music: Music, + /// Blacklist configuration. + pub blacklist: Blacklist, + /// Self signed certificate option + pub self_signed_cert: bool, + /// Simple episode name + pub show_simple: bool, + /// Add "0" before season/episode number if lower than 10. + pub append_prefix: bool, + /// Add a divider between numbers + pub add_divider: bool, +} + +pub struct Music { + pub display: Option>, + pub separator: Option, +} + +pub struct Discord { + /// Set a custom Application ID to be used. + pub application_id: Option, + /// Set custom buttons to be displayed. + pub buttons: Option>, + /// Show status when media is paused + pub show_paused: bool, +} + +/// Images configuration +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Images { + /// Enables images, not everyone wants them so its a toggle. + pub enable_images: bool, + /// Enables imgur images. + pub imgur_images: bool, +} + +impl Config { + pub fn builder() -> ConfigBuilder { + ConfigBuilder::new() + } +} + +/// Main struct containing every other struct in the file. +/// +/// The config file is parsed into this struct. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct ConfigBuilder { + /// Jellyfin configuration. + /// + /// Has every required part of the config, hence why its not an `Option`. + pub jellyfin: JellyfinBuilder, + /// Discord configuration. + pub discord: Option, + /// Imgur configuration. + pub imgur: Option, + /// Images configuration. + pub images: Option, +} + +/// This struct contains every "required" part of the config. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct JellyfinBuilder { + /// URL to the jellyfin server. + pub url: String, + /// Api key from the jellyfin server, used to gather what's being watched. + pub api_key: String, + /// Username of the person that info should be gathered from. + pub username: Username, + /// Contains configuration for Music display. + pub music: Option, + /// Blacklist configuration. + pub blacklist: Option, + /// Self signed certificate option + pub self_signed_cert: Option, + /// Simple episode name + pub show_simple: Option, + /// Add "0" before season/episode number if lower than 10. + pub append_prefix: Option, + /// Add a divider between numbers + pub add_divider: Option, +} + +/// Username of the person that info should be gathered from. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum Username { + /// If the username is a `Vec`. + Vec(Vec), + /// If the username is a `String`. + String(String), +} + +/// Contains configuration for Music display. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct MusicBuilder { + /// Display is where you tell the program what should be displayed. + /// + /// Example: `vec![String::from("genres"), String::from("year")]` + pub display: Option, + /// Separator is what should be between the artist(s) and the `display` options. + pub separator: Option, +} + +/// Display is where you tell the program what should be displayed. +/// +/// Example: `vec![String::from("genres"), String::from("year")]` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum Display { + /// If the Display is a `Vec`. + Vec(Vec), + /// If the Display is a comma separated `String`. + String(String), +} + +/// Blacklist MediaTypes and libraries. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Blacklist { + /// `Vec` of MediaTypes to blacklist + pub media_types: Option>, + /// `Vec` of libraries to blacklist + pub libraries: Option>, +} + +/// Discord configuration +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct DiscordBuilder { + /// Set a custom Application ID to be used. + pub application_id: Option, + /// Set custom buttons to be displayed. + pub buttons: Option>, + /// Show status when media is paused + pub show_paused: Option, +} + +/// Imgur configuration +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Imgur { + /// Contains the client ID used to upload images to imgur. + pub client_id: Option, +} + +/// Images configuration +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ImagesBuilder { + /// Enables images, not everyone wants them so its a toggle. + pub enable_images: Option, + /// Enables imgur images. + pub imgur_images: Option, +} + + +/// Find urls.json in filesystem, used to store images that were already previously uploaded to imgur. +/// +/// This is to avoid the user having to specify a filepath on launch. +/// +/// Default urls.json path depends on OS +/// Windows: `%appdata%\jellyfin-rpc\urls.json` +/// Linux/macOS: `~/.config/jellyfin-rpc/urls.json` +pub fn get_urls_path() -> Result> { + if cfg!(not(windows)) { + debug!("Platform is not Windows"); + let xdg_config_home = match env::var("XDG_CONFIG_HOME") { + Ok(xdg_config_home) => xdg_config_home, + Err(_) => env::var("HOME")? + "/.config", + }; + + Ok(xdg_config_home + ("/jellyfin-rpc/urls.json")) + } else { + debug!("Platform is Windows"); + let app_data = env::var("APPDATA")?; + Ok(app_data + r"\jellyfin-rpc\urls.json") + } +} + +/// Find config.json in filesystem. +/// +/// This is to avoid the user having to specify a filepath on launch. +/// +/// Default config path depends on OS +/// Windows: `%appdata%\jellyfin-rpc\config.json` +/// Linux/macOS: `~/.config/jellyfin-rpc/config.json` +pub fn get_config_path() -> Result> { + debug!("Getting config path"); + if cfg!(not(windows)) { + debug!("Platform is not Windows"); + let xdg_config_home = match env::var("XDG_CONFIG_HOME") { + Ok(xdg_config_home) => xdg_config_home, + Err(_) => env::var("HOME")? + "/.config", + }; + + Ok(xdg_config_home + "/jellyfin-rpc/main.json") + } else { + debug!("Platform is Windows"); + let app_data = env::var("APPDATA")?; + Ok(app_data + r"\jellyfin-rpc\main.json") + } +} + +impl ConfigBuilder { + fn new() -> Self { + Self { + jellyfin: JellyfinBuilder { + url: "".to_string(), + username: Username::String("".to_string()), + api_key: "".to_string(), + music: None, + blacklist: None, + self_signed_cert: None, + show_simple: Some(false), + append_prefix: Some(false), + add_divider: Some(false), + }, + discord: None, + imgur: None, + images: None, + } + } + + /// Loads the config from the given path. + pub fn load(self, path: &str) -> Result> { + debug!("Config path is: {}", path); + + let data = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&data)?; + + debug!("Config loaded successfully"); + + Ok(config) + } + + pub fn build(self) -> Config { + let username = match self.jellyfin.username { + Username::Vec(usernames) => usernames, + Username::String(username) => username + .split(",") + .map(|u| u.to_string()) + .collect(), + }; + + let display; + let separator; + + if let Some(music) = self.jellyfin.music { + if let Some(disp) = music.display { + display = Some(match disp { + Display::Vec(display) => display, + Display::String(display) => display + .split(",") + .map(|d| d.to_string()) + .collect(), + }) + } else { + display = None; + } + + separator = music.separator; + } else { + display = None; + separator = None; + } + + let media_types; + let libraries; + + if let Some(blacklist) = self.jellyfin.blacklist { + media_types = blacklist.media_types; + libraries = blacklist.libraries; + } else { + media_types = None; + libraries = None; + } + + let application_id; + let buttons; + let show_paused; + + if let Some(discord) = self.discord { + application_id = discord.application_id; + buttons = discord.buttons; + show_paused = discord.show_paused.unwrap_or(true) + } else { + application_id = None; + buttons = None; + show_paused = true; + } + + let client_id; + + if let Some(imgur) = self.imgur { + client_id = imgur.client_id; + } else { + client_id = None + } + + let enable_images; + let imgur_images; + + if let Some(images) = self.images { + enable_images = images.enable_images.unwrap_or(false); + imgur_images = images.imgur_images.unwrap_or(false); + } else { + enable_images = false; + imgur_images = false; + } + + Config { + jellyfin: Jellyfin { + url: self.jellyfin.url, + api_key: self.jellyfin.api_key, + username, + music: Music { + display, + separator, + }, + blacklist: Blacklist { + media_types, + libraries, + }, + self_signed_cert: self.jellyfin.self_signed_cert.unwrap_or(false), + show_simple: self.jellyfin.show_simple.unwrap_or(false), + append_prefix: self.jellyfin.append_prefix.unwrap_or(false), + add_divider: self.jellyfin.add_divider.unwrap_or(false), + }, + discord: Discord { + application_id, + buttons, + show_paused, + }, + imgur: Imgur { + client_id: client_id, + }, + images: Images { + enable_images, + imgur_images, + }, + } + } +} diff --git a/jellyfin-rpc-cli/src/main.rs b/jellyfin-rpc-cli/src/main.rs index 1ba8754..273df90 100644 --- a/jellyfin-rpc-cli/src/main.rs +++ b/jellyfin-rpc-cli/src/main.rs @@ -1,15 +1,17 @@ +use std::{ + thread::sleep, + time::Duration +}; use clap::Parser; -use colored::Colorize; -pub use jellyfin_rpc::core::rpc::show_paused; -use jellyfin_rpc::discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; -pub use jellyfin_rpc::prelude::*; -pub use jellyfin_rpc::services::imgur::*; -use log::{error, info, warn}; +use config::{get_config_path, get_urls_path, Config}; +use jellyfin_rpc::Client; +use log::{debug, error, info}; use retry::retry_with_index; use simple_logger::SimpleLogger; use time::macros::format_description; #[cfg(feature = "updates")] mod updates; +mod config; /* TODO: Comments @@ -37,13 +39,6 @@ struct Args { default_value_t = 3 )] wait_time: usize, - #[arg( - short = 's', - long = "suppress-warnings", - help = "Stops warnings from showing on startup", - default_value_t = false - )] - suppress_warnings: bool, #[arg( short = 'v', long = "log-level", @@ -53,15 +48,11 @@ struct Args { log_level: String, } -#[tokio::main] -async fn main() -> Result<(), Box> { +fn main() -> Result<(), Box> { let args = Args::parse(); if std::env::var("RUST_LOG").is_err() { - let _ = tokio::task::spawn_blocking(move || { - std::env::set_var("RUST_LOG", args.log_level); - }) - .await; + let _ = std::env::set_var("RUST_LOG", args.log_level); } SimpleLogger::new() @@ -76,269 +67,112 @@ async fn main() -> Result<(), Box> { info!("Initializing Jellyfin-RPC"); #[cfg(feature = "updates")] - updates::checker().await; - - let config_path = args.config.unwrap_or_else(|| { - get_config_path().unwrap_or_else(|err| { - error!("Error determining config path: {:?}", err); - std::process::exit(1) - }) - }); - - std::fs::create_dir_all( - std::path::Path::new(&config_path) - .parent() - .expect("Invalid config file path"), - ) - .ok(); - - let config = Config::load(&config_path).unwrap_or_else(|e| { - error!("{} {:?}", "Config can't be loaded:".bold().red(), e); - error!( - "{} {}", - "Config file should be located at:".bold().red(), - config_path - ); - std::process::exit(2) - }); - - if !args.suppress_warnings && config.jellyfin.self_signed_cert.is_some_and(|val| val) { - warn!("{}", "Self-signed certificates are enabled!".bold().red()); + updates::checker(); + + let conf = Config::builder() + .load(&args.config.unwrap_or(get_config_path()?))? + .build(); + + debug!("Creating jellyfin-rpc client builder"); + let mut builder = Client::builder(); + + builder + .api_key(conf.jellyfin.api_key) + .url(conf.jellyfin.url) + .usernames(conf.jellyfin.username) + .self_signed(conf.jellyfin.self_signed_cert) + .episode_simple(conf.jellyfin.show_simple) + .episode_divider(conf.jellyfin.add_divider) + .episode_prefix(conf.jellyfin.append_prefix) + .show_paused(conf.discord.show_paused) + .show_images(conf.images.enable_images) + .use_imgur(conf.images.imgur_images) + .large_image_text(format!("Jellyfin-RPC v{}", VERSION.unwrap_or("UNKNOWN"))) + .imgur_urls_file_location(args.image_urls.unwrap_or(get_urls_path()?)); + + if let Some(display) = conf.jellyfin.music.display { + debug!("Found config.jellyfin.music.display"); + builder.music_display(display); } - if !args.suppress_warnings - && config - .clone() - .images - .and_then(|images| images.enable_images) - .unwrap_or(false) - && !config - .clone() - .images - .and_then(|images| images.imgur_images) - .unwrap_or(false) - { - warn!( - "{}", - "Images without Imgur requires port forwarding!" - .bold() - .red() - ) + if let Some(separator) = conf.jellyfin.music.separator { + debug!("Found config.jellyfin.music.separator"); + builder.music_separator(separator); } - if config.jellyfin.blacklist.is_some() { - let blacklist = config.jellyfin.blacklist.clone().unwrap(); - if let Some(media_types) = blacklist.media_types { - if !media_types.is_empty() { - info!( - "{} {}", - "These media types won't be shown:".bold().red(), - media_types - .iter() - .map(|x| x.to_string()) - .collect::>() - .join(", ") - .bold() - .red() - ) - } - } - if let Some(libraries) = blacklist.libraries { - if !libraries.is_empty() { - info!( - "{} {}", - "These media libraries won't be shown:".bold().red(), - libraries.join(", ").bold().red() - ) - } - } + if let Some(media_types) = conf.jellyfin.blacklist.media_types { + debug!("Found config.jellyfin.blacklist.media_types"); + builder.blacklist_media_types(media_types); } - let mut connected: bool = false; - let mut rich_presence_client = DiscordIpcClient::new( - config - .discord - .clone() - .and_then(|discord| discord.application_id) - .unwrap_or(String::from("1053747938519679018")) - .as_str(), - ) - .expect("Failed to create Discord RPC client, discord is down or the Client ID is invalid."); + if let Some(libraries) = conf.jellyfin.blacklist.libraries { + debug!("Found config.jellyfin.blacklist.libraries"); + builder.blacklist_libraries(libraries); + } - // Start up the client connection, so that we can actually send and receive stuff - jellyfin_rpc::connect(&mut rich_presence_client); - info!( - "{}", - "Connected to Discord Rich Presence Socket" - .bright_green() - .bold(), - ); + if let Some(application_id) = conf.discord.application_id { + debug!("Found config.discord.application_id"); + builder.client_id(application_id); + } - // Start loop - loop { - let mut content = Content::try_get(&config, 1).await; + if let Some(buttons) = conf.discord.buttons { + debug!("Found config.discord.buttons"); + builder.buttons(buttons); + } - let mut blacklist_check = true; - config - .clone() - .jellyfin - .blacklist - .and_then(|blacklist| blacklist.media_types) - .unwrap_or(vec![MediaType::None]) - .iter() - .for_each(|x| { - if blacklist_check && !content.media_type.is_none() { - blacklist_check = content.media_type != *x - } - }); - if config - .clone() - .jellyfin - .blacklist - .and_then(|blacklist| blacklist.libraries) - .is_some() - { - for library in &config - .clone() - .jellyfin - .blacklist - .and_then(|blacklist| blacklist.libraries) - .unwrap() - { - if blacklist_check && !content.media_type.is_none() { - blacklist_check = jellyfin::library_check( - &config.jellyfin.url, - &config.jellyfin.api_key, - &content.item_id, - library, - config.jellyfin.self_signed_cert.unwrap_or(false), - ) - .await?; - } - } + if let Some(client_id) = conf.imgur.client_id { + debug!("Found config.imgur.client_id"); + builder.imgur_client_id(client_id); + } + + debug!("Building client"); + let mut client = builder.build()?; + + info!("Connecting to Discord"); + retry_with_index(retry::delay::Exponential::from_millis(1000), |current_try| { + info!("Attempt {}: Trying to connect", current_try); + match client.connect() { + Ok(_) => retry::OperationResult::Ok(()), + Err(err) => { + error!("{}", err); + retry::OperationResult::Retry(()) + }, } + }).unwrap(); - if !content.media_type.is_none() - && blacklist_check - && show_paused(&content.media_type, content.endtime, &config.discord) - { - // Print what we're watching - if !connected { - info!("{}", content.details.bright_cyan().bold()); - info!("{}", content.state_message.bright_cyan().bold()); - // Set connected to true so that we don't try to connect again - connected = true; - } - if config - .clone() - .images - .and_then(|images| images.imgur_images) - .unwrap_or(false) - && content.media_type != MediaType::LiveTv - { - content.image_url = Imgur::get( - &content.image_url, - &content.item_id, - &config - .clone() - .imgur - .and_then(|imgur| imgur.client_id) - .expect("Imgur client ID cant be loaded."), - args.image_urls.clone(), - config.jellyfin.self_signed_cert.unwrap_or(false), - ) - .await - .unwrap_or_else(|e| { - error!("{}", format!("Failed to use Imgur: {:?}", e).red().bold()); - Imgur::default() - }) - .url; - } + let mut currently_playing = String::new(); - // Set the activity - let mut rpcbuttons: Vec = vec![]; - let mut x = 0; - let buttons = config - .clone() - .discord - .and_then(|discord| discord.buttons) - .unwrap_or(vec![config::Button::default(), config::Button::default()]); - - // For loop to determine if external services are to be used or if there are custom buttons instead - for button in buttons.iter() { - if button.name == "dynamic" - && button.url == "dynamic" - && content.external_services.len() != x - { - rpcbuttons.push(activity::Button::new( - &content.external_services[x].name, - &content.external_services[x].url, - )); - x += 1 - } else if button.name != "dynamic" || button.url != "dynamic" { - rpcbuttons.push(activity::Button::new(&button.name, &button.url)) - } - - // Exit early if there's 2 buttons already present, as this is Discord's cap - if rpcbuttons.len() == 2 { - break; + loop { + match client.set_activity() { + Ok(activity) => { + if activity.is_empty() && !currently_playing.is_empty() { + let _ = client.clear_activity(); + info!("Cleared activity"); + currently_playing = activity; + } else if activity != currently_playing { + currently_playing = activity; + + info!("{}", currently_playing); } - } - rich_presence_client - .set_activity(jellyfin_rpc::setactivity( - &content.state_message, - &content.details, - content.endtime, - &content.image_url, - rpcbuttons, - format!("Jellyfin-RPC v{}", VERSION.unwrap_or("0.0.0")).as_str(), - &content.media_type, - )) - .unwrap_or_else(|err| { - error!("{}\nError: {}", "Failed to set activity".red().bold(), err); - retry_with_index( - retry::delay::Exponential::from_millis(1000), - |current_try| { - info!( - "{} {}{}", - "Attempt".bold().truecolor(225, 69, 0), - current_try.to_string().bold().truecolor(225, 69, 0), - ": Trying to reconnect".bold().truecolor(225, 69, 0) - ); - match rich_presence_client.reconnect() { - Ok(result) => retry::OperationResult::Ok(result), - Err(err) => { - error!( - "{}\nError: {}", - "Failed to reconnect, retrying soon".red().bold(), - err - ); - retry::OperationResult::Retry(()) - } - } + }, + Err(err) => { + error!("{}", err); + retry_with_index(retry::delay::Exponential::from_millis(1000), |current_try| { + info!("Attempt {}: Trying to reconnect", current_try); + match client.reconnect() { + Ok(_) => retry::OperationResult::Ok(()), + Err(err) => { + error!("{}", err); + retry::OperationResult::Retry(()) }, - ) - .unwrap(); - info!( - "{}", - "Reconnected to Discord Rich Presence Socket" - .bright_green() - .bold(), - ); - info!("{}", content.details.bright_cyan().bold()); - info!("{}", content.state_message.bright_cyan().bold()); - }); - } else if connected { - // Disconnect from the client - rich_presence_client - .clear_activity() - .expect("Failed to clear activity"); - // Set connected to false so that we dont try to disconnect again - connected = false; - info!("{}", "Cleared Rich Presence".bright_red().bold(),); + } + }).unwrap(); + + client.set_activity()?; + }, } - tokio::time::sleep(tokio::time::Duration::from_secs(args.wait_time as u64)).await; + sleep(Duration::from_secs(args.wait_time as u64)); } } diff --git a/jellyfin-rpc-cli/src/updates.rs b/jellyfin-rpc-cli/src/updates.rs index dcd2025..673b293 100644 --- a/jellyfin-rpc-cli/src/updates.rs +++ b/jellyfin-rpc-cli/src/updates.rs @@ -2,9 +2,9 @@ use crate::VERSION; use colored::Colorize; use log::warn; -pub async fn checker() { +pub fn checker() { let current = VERSION.unwrap_or("0.0.0").to_string(); - let latest = get_latest_github().await.unwrap_or(current.clone()); + let latest = get_latest_github().unwrap_or(current.clone()); if latest != current { warn!( "{} (Current: v{}, Latest: v{})", @@ -28,9 +28,8 @@ pub async fn checker() { } } -async fn get_latest_github() -> Result { - let url = reqwest::get("https://github.com/Radiicall/jellyfin-rpc/releases/latest") - .await? +fn get_latest_github() -> Result { + let url = reqwest::blocking::get("https://github.com/Radiicall/jellyfin-rpc/releases/latest")? .url() .as_str() .trim_start_matches("https://github.com/Radiicall/jellyfin-rpc/releases/tag/") diff --git a/jellyfin-rpc/Cargo.toml b/jellyfin-rpc/Cargo.toml index ff319b7..9366b0f 100644 --- a/jellyfin-rpc/Cargo.toml +++ b/jellyfin-rpc/Cargo.toml @@ -1,25 +1,18 @@ [package] name = "jellyfin-rpc" -version = "1.1.0" +version = "1.2.0" edition = "2021" description = "Backend for the Jellyfin-RPC-cli and Jellyfin-RPC-Iced projects" license = "GPL-3.0-or-later" repository = "https://github.com/Radiicall/jellyfin-rpc" keywords = ["jellyfin", "discord", "rich-presence"] -[features] -imgur = [] - [dependencies] discord-rich-presence = "0.2" retry = "2.0" serde_json = "1.0" -async-recursion = "1.1" -tokio = "1" log = "0.4" - -[dependencies.colored] -version = "2.1" +url = "2.5" [dependencies.serde] features = ["derive"] @@ -27,8 +20,5 @@ version = "1.0" [dependencies.reqwest] default-features = false -features = ["rustls-tls", "json"] +features = ["rustls-tls", "json", "blocking"] version = "0.12" - -[package.metadata.docs.rs] -features = ["imgur"] diff --git a/jellyfin-rpc/example.json b/jellyfin-rpc/example.json deleted file mode 100644 index 4b9f9e3..0000000 --- a/jellyfin-rpc/example.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "jellyfin": { - "url": "https://example.com", - "api_key": "sadasodsapasdskd", - "username": "your_username_here", - "music": { - "display": "genres", - "separator": "-" - }, - "self_signed_cert": false, - "show_simple": false, - "append_prefix": false, - "add_divider": false, - "_comment": "the 4 lines below and this line arent needed and should be removed, by default nothing will display if these are present", - "blacklist": { - "media_types": ["music", "movie", "episode", "livetv"], - "libraries": ["Anime", "Anime Movies"] - } - }, - "discord": { - "application_id": "1053747938519679018", - "buttons": [ - { - "name": "dynamic", - "url": "dynamic" - }, - { - "name": "dynamic", - "url": "dynamic" - } - ], - "show_paused": true - }, - "imgur": { - "client_id": "asdjdjdg394209fdjs093" - }, - "images": { - "enable_images": true, - "imgur_images": true - } -} diff --git a/jellyfin-rpc/src/core/config.rs b/jellyfin-rpc/src/core/config.rs deleted file mode 100644 index 5626cb2..0000000 --- a/jellyfin-rpc/src/core/config.rs +++ /dev/null @@ -1,195 +0,0 @@ -use super::error::ConfigError; -use crate::prelude::MediaType; -use log::debug; -use serde::{Deserialize, Serialize}; -use serde_json; -use std::env; - -/// Main struct containing every other struct in the file. -/// -/// The config file is parsed into this struct. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "lowercase")] -pub struct Config { - /// Jellyfin configuration. - /// - /// Has every required part of the config, hence why its not an `Option`. - pub jellyfin: Jellyfin, - /// Discord configuration. - pub discord: Option, - /// Imgur configuration. - pub imgur: Option, - /// Images configuration. - pub images: Option, -} - -/// This struct contains every "required" part of the config. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Jellyfin { - /// URL to the jellyfin server. - pub url: String, - /// Api key from the jellyfin server, used to gather what's being watched. - pub api_key: String, - /// Username of the person that info should be gathered from. - pub username: Username, - /// Contains configuration for Music display. - pub music: Option, - /// Blacklist configuration. - pub blacklist: Option, - /// Self signed certificate option - pub self_signed_cert: Option, - /// Simple episode name - pub show_simple: Option, - /// Add "0" before season/episode number if lower than 10. - pub append_prefix: Option, - /// Add a divider between numbers - pub add_divider: Option, -} - -/// Username of the person that info should be gathered from. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(untagged)] -pub enum Username { - /// If the username is a `Vec`. - Vec(Vec), - /// If the username is a `String`. - String(String), -} - -/// Contains configuration for Music display. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Music { - /// Display is where you tell the program what should be displayed. - /// - /// Example: `vec![String::from("genres"), String::from("year")]` - pub display: Option, - /// Separator is what should be between the artist(s) and the `display` options. - pub separator: Option, -} - -/// Display is where you tell the program what should be displayed. -/// -/// Example: `vec![String::from("genres"), String::from("year")]` -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(untagged)] -pub enum Display { - /// If the Display is a `Vec`. - Vec(Vec), - /// If the Display is a comma separated `String`. - String(String), -} - -/// Blacklist MediaTypes and libraries. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Blacklist { - /// `Vec` of MediaTypes to blacklist - pub media_types: Option>, - /// `Vec` of libraries to blacklist - pub libraries: Option>, -} - -/// Discord configuration -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Discord { - /// Set a custom Application ID to be used. - pub application_id: Option, - /// Set custom buttons to be displayed. - pub buttons: Option>, - /// Show status when media is paused - pub show_paused: Option, -} - -/// Button struct -/// -/// Contains information about buttons -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Button { - /// What the name should be showed as in Discord. - pub name: String, - /// What clicking it should point to in Discord. - pub url: String, -} - -impl Default for Button { - fn default() -> Self { - Self { - name: String::from("dynamic"), - url: String::from("dynamic"), - } - } -} - -/// Imgur configuration -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Imgur { - /// Contains the client ID used to upload images to imgur. - pub client_id: Option, -} - -/// Images configuration -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct Images { - /// Enables images, not everyone wants them so its a toggle. - pub enable_images: Option, - /// Enables imgur images. - pub imgur_images: Option, -} - -/// Find config.json in filesystem. -/// -/// This is to avoid the user having to specify a filepath on launch. -/// -/// Default config path depends on OS -/// Windows: `%appdata%\jellyfin-rpc\config.json` -/// Linux/macOS: `~/.config/jellyfin-rpc/config.json` -pub fn get_config_path() -> Result { - debug!("Getting config path"); - if cfg!(not(windows)) { - debug!("Platform is not Windows"); - let xdg_config_home = match env::var("XDG_CONFIG_HOME") { - Ok(xdg_config_home) => xdg_config_home, - Err(_) => env::var("HOME")? + "/.config", - }; - - Ok(xdg_config_home + "/jellyfin-rpc/main.json") - } else { - debug!("Platform is Windows"); - let app_data = env::var("APPDATA")?; - Ok(app_data + r"\jellyfin-rpc\main.json") - } -} - -impl Config { - /// Loads the config from the given path. - pub fn load(path: &str) -> Result { - debug!("Config path is: {}", path); - - let data = std::fs::read_to_string(path)?; - let config: Config = serde_json::from_str(&data)?; - - debug!("Config loaded successfully"); - - Ok(config) - } -} - -impl Default for Config { - fn default() -> Self { - Self { - jellyfin: Jellyfin { - url: "".to_string(), - username: Username::String("".to_string()), - api_key: "".to_string(), - music: None, - blacklist: None, - self_signed_cert: None, - show_simple: Some(false), - append_prefix: Some(false), - add_divider: Some(false), - }, - discord: None, - imgur: None, - images: None, - } - } -} diff --git a/jellyfin-rpc/src/core/error.rs b/jellyfin-rpc/src/core/error.rs deleted file mode 100644 index 53a7496..0000000 --- a/jellyfin-rpc/src/core/error.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::env; - -/// Error type for the config module. -#[derive(Debug)] -pub enum ConfigError { - /// Returns when it can't find the config file. - MissingConfig(String), - /// Returns when it can't read the config file. - Io(String), - /// Returns when it's unable to parse the config file to the Config struct. - Json(String), - /// Returns when environment variables fail to be read. - VarError(String), -} - -impl From<&'static str> for ConfigError { - fn from(value: &'static str) -> Self { - Self::MissingConfig(value.to_string()) - } -} - -impl From for ConfigError { - fn from(value: std::io::Error) -> Self { - Self::Io(format!("Unable to open file: {}", value)) - } -} - -impl From for ConfigError { - fn from(value: serde_json::Error) -> Self { - Self::Json(format!("Unable to parse config: {}", value)) - } -} - -impl From for ConfigError { - fn from(value: env::VarError) -> Self { - Self::VarError(format!("Unable to get environment variables: {}", value)) - } -} - -/// Error type for the imgur module. -#[derive(Debug)] -pub enum ImgurError { - /// Returns when the response from the Imgur API is invalid. - /// - /// This is usually due to a bad API key or something wrong with the image its trying to upload. - InvalidResponse, - /// Returns on errors in the reqwest library, can happen when trying to upload a file. - Reqwest(String), - /// Returns when it can't read the urls.json file. - Io(String), - /// Returns when it can't parse the urls.json file. - Json(String), - /// Returns when environment variables fail to be read. - VarError(String), - /// Returns when a required `Option` is `None`. - None, -} - -impl From for ImgurError { - fn from(value: reqwest::Error) -> Self { - Self::Reqwest(format!("Error uploading image: {}", value)) - } -} - -impl From for ImgurError { - fn from(value: std::io::Error) -> Self { - Self::Io(format!("Unable to open file: {}", value)) - } -} - -impl From for ImgurError { - fn from(value: serde_json::Error) -> Self { - Self::Json(format!("Unable to parse urls: {}", value)) - } -} - -impl From for ImgurError { - fn from(value: env::VarError) -> Self { - Self::VarError(format!("Unable to get environment variables: {}", value)) - } -} - -/// Error type for the jellyfin module -// TODO: Rename to `JellyfinError` -#[derive(Debug)] -pub enum ContentError { - /// Returns on errors in the reqwest library, can happen when trying to access the Jellyfin server. - Reqwest(reqwest::Error, String), - /// Returns when the reply from jellyfin can't be parsed to the needed types. - Json(serde_json::Error), -} - -impl From for ContentError { - fn from(value: serde_json::Error) -> Self { - Self::Json(value) - } -} - -impl From for ContentError { - fn from(value: reqwest::Error) -> Self { - Self::Reqwest(value, "Is your Jellyfin URL set correctly?".to_string()) - } -} diff --git a/jellyfin-rpc/src/core/mod.rs b/jellyfin-rpc/src/core/mod.rs deleted file mode 100644 index c6c4f52..0000000 --- a/jellyfin-rpc/src/core/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -/// Module for config.json file -pub mod config; -/// Module containing error types used by the library. -pub mod error; -pub mod rpc; diff --git a/jellyfin-rpc/src/core/rpc.rs b/jellyfin-rpc/src/core/rpc.rs deleted file mode 100644 index 0e79131..0000000 --- a/jellyfin-rpc/src/core/rpc.rs +++ /dev/null @@ -1,76 +0,0 @@ -use super::config::Discord; -use crate::prelude::MediaType; -use discord_rich_presence::activity; - -/// Used to set the activity on Discord. -/// -/// This has checks to do different things for different mediatypes and replaces images with default ones if they are needed. -pub fn setactivity<'a>( - state_message: &'a str, - details: &'a str, - endtime: Option, - img_url: &'a str, - rpcbuttons: Vec>, - version: &'a str, - media_type: &'a MediaType, -) -> activity::Activity<'a> { - let mut new_activity = activity::Activity::new().details(details); - - let mut image_url = "https://i.imgur.com/oX6vcds.png"; - - if media_type == &MediaType::LiveTv { - image_url = "https://i.imgur.com/XxdHOqm.png" - } else if !img_url.is_empty() { - image_url = img_url; - } - - let mut assets = activity::Assets::new() - .large_text(version) - .large_image(image_url); - - match endtime { - Some(_) if media_type == &MediaType::LiveTv => (), - Some(time) => { - new_activity = new_activity - .clone() - .timestamps(activity::Timestamps::new().end(time)); - } - None if media_type == &MediaType::Book => (), - None => { - assets = assets - .clone() - .small_image("https://i.imgur.com/wlHSvYy.png") - .small_text("Paused"); - } - } - - if !state_message.is_empty() { - new_activity = new_activity.clone().state(state_message); - } - if !rpcbuttons.is_empty() { - new_activity = new_activity.clone().buttons(rpcbuttons); - } - new_activity = new_activity.clone().assets(assets); - - new_activity -} - -pub fn show_paused<'a>( - media_type: &'a MediaType, - endtime: Option, - discord: &'a Option, -) -> bool { - if media_type == &MediaType::Book { - return true; - } - - if endtime.is_some() { - return true; - } - - if let Some(discord) = discord { - return discord.show_paused.unwrap_or(true); - } - - true -} diff --git a/jellyfin-rpc/src/error.rs b/jellyfin-rpc/src/error.rs new file mode 100644 index 0000000..d41f8c1 --- /dev/null +++ b/jellyfin-rpc/src/error.rs @@ -0,0 +1,18 @@ +use std::{error::Error, fmt::Display}; + +#[derive(Debug)] +pub enum JfError { + UnrecognizedMediaType, + ContentBlacklist, +} + +impl Error for JfError {} + +impl Display for JfError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JfError::UnrecognizedMediaType => write!(f, "Unrecognized media type"), + JfError::ContentBlacklist => write!(f, "Content is blacklisted"), + } + } +} diff --git a/jellyfin-rpc/src/external/imgur.rs b/jellyfin-rpc/src/external/imgur.rs new file mode 100644 index 0000000..fa81816 --- /dev/null +++ b/jellyfin-rpc/src/external/imgur.rs @@ -0,0 +1,102 @@ +use std::{fs::{self, File, OpenOptions}, io::{Error, ErrorKind, Write}, path::Path}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{Client, JfResult}; + + +#[derive(Deserialize, Serialize)] +struct ImageUrl { + id: String, + pub url: String, +} + +impl ImageUrl { + fn new, Y: Into>(id: T, url: Y) -> Self { + Self { + id: id.into(), + url: url.into(), + } + } +} + +#[derive(Deserialize)] +struct ImgurResponse { + data: Data, +} + +#[derive(Deserialize)] +struct Data { + link: String, +} + +pub fn get_image(client: &Client) -> JfResult { + let mut image_urls = read_file(client)?; + + + if let Some(image_url) = image_urls.iter().find(|image_url| client.session.as_ref().unwrap().item_id == image_url.id) { + Ok(Url::parse(&image_url.url)?) + } else { + let imgur_url = upload(client)?; + + let image_url = ImageUrl::new( + &client.session.as_ref().unwrap().item_id, + imgur_url.as_str() + ); + + image_urls.push(image_url); + + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(&client.imgur_options.urls_location)?; + + file.write_all(serde_json::to_string(&image_urls)?.as_bytes())?; + + let _ = file.flush(); + + Ok(imgur_url) + } +} + +fn read_file(client: &Client) -> JfResult> { + if let Ok(contents_raw) = fs::read_to_string(&client.imgur_options.urls_location) { + if let Ok(contents) = serde_json::from_str::>(&contents_raw) { + return Ok(contents) + } + } + + let path = Path::new(&client.imgur_options.urls_location).parent() + .ok_or(Error::new(ErrorKind::Other, "Can't find parent folder of urls.json"))?; + + fs::create_dir_all(path)?; + + let mut file = File::create(client.imgur_options.urls_location.clone())?; + + let new: Vec = vec![]; + + file.write_all(serde_json::to_string(&new)?.as_bytes())?; + + let _ = file.flush(); + + Ok(new) +} + +fn upload(client: &Client) -> JfResult { + let image_bytes = client.reqwest.get(client.get_image()?) + .send()? + .bytes()?; + + let res: ImgurResponse = client.reqwest + .post("https://api.imgur.com/3/image") + .header( + reqwest::header::AUTHORIZATION, + format!("Client-ID {}", client.imgur_options.client_id) + ) + .body(image_bytes) + .send()? + .json()?; + + Ok(Url::parse(res.data.link.as_str())?) +} diff --git a/jellyfin-rpc/src/external/mod.rs b/jellyfin-rpc/src/external/mod.rs new file mode 100644 index 0000000..36e17d4 --- /dev/null +++ b/jellyfin-rpc/src/external/mod.rs @@ -0,0 +1 @@ +pub mod imgur; diff --git a/jellyfin-rpc/src/jellyfin.rs b/jellyfin-rpc/src/jellyfin.rs new file mode 100644 index 0000000..3619ca4 --- /dev/null +++ b/jellyfin-rpc/src/jellyfin.rs @@ -0,0 +1,316 @@ +use serde::{de::Visitor, Deserialize, Serialize}; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct RawSession { + pub user_name: String, + pub now_playing_item: Option, + pub play_state: PlayState, +} + +impl RawSession { + pub fn build(self) -> Session { + //TODO: Figure out how to avoid this clone + let now_playing_item = self.now_playing_item.clone().unwrap(); + let id; + + match now_playing_item.media_type { + MediaType::Episode => { + id = now_playing_item.series_id + .unwrap_or(now_playing_item.id) + }, + MediaType::Music => { + id = now_playing_item.album_id + .unwrap_or(now_playing_item.id) + }, + _ => { + id = now_playing_item.id + } + }; + + Session { + now_playing_item: self.now_playing_item.unwrap(), + play_state: self.play_state, + item_id: id.to_string(), + } + } +} + +#[derive(Debug)] +pub struct Session { + pub now_playing_item: NowPlayingItem, + pub play_state: PlayState, + pub item_id: String, +} + +impl Session { + pub fn get_details(&self) -> &str { + match self.now_playing_item.media_type { + MediaType::Episode => self.now_playing_item.series_name.as_ref().unwrap_or(&self.now_playing_item.name), + MediaType::AudioBook => self.now_playing_item.album.as_ref().unwrap_or(&self.now_playing_item.name), + _ => &self.now_playing_item.name, + } + } + + /// Formats artists with comma separation and a final "and" before the last name. + pub fn format_artists(&self) -> String { + // let default is to create a longer lived value for artists_vec + let default = vec!["".to_string()]; + let artists_vec = self.now_playing_item.artists.as_ref().unwrap_or(&default); + let mut artists = String::new(); + + for i in 0..artists_vec.len() { + if i == 0 { + artists += &artists_vec[i]; + continue + } + + if i == artists_vec.len() - 1 { + artists += &format!(" and {}", artists_vec[i]); + continue + } + + artists += &format!(", {}", artists_vec[i]); + } + + artists + } + + pub fn get_endtime(&self) -> Result { + match self.now_playing_item.media_type { + MediaType::Book => return Ok(EndTime::NoEndTime), + MediaType::LiveTv => return Ok(EndTime::NoEndTime), + _ => {} + } + + if !self.play_state.is_paused { + let ticks_to_seconds = 10000000; + + if let Some(mut position_ticks) = self.play_state.position_ticks { + position_ticks /= ticks_to_seconds; + + let runtime_ticks = self.now_playing_item.run_time_ticks / ticks_to_seconds; + + return Ok( + EndTime::Some(SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() as i64 + + (runtime_ticks - position_ticks)) + ) + } + } + Ok(EndTime::Paused) + } +} + +#[derive(PartialEq)] +pub enum EndTime { + Some(i64), + NoEndTime, + Paused, +} + +/// Button struct +/// +/// Contains information about buttons +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Button { + /// What the name should be showed as in Discord. + pub name: String, + /// What clicking it should point to in Discord. + pub url: String, +} + +impl Default for Button { + fn default() -> Self { + Self { + name: String::from("dynamic"), + url: String::from("dynamic"), + } + } +} + +impl Button { + pub fn new(name: String, url: String) -> Self { + Self { + name, + url, + } + } + + pub fn is_dynamic(&self) -> bool { + self.name == "dynamic" && self.url == "dynamic" + } +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct NowPlayingItem { + // Generic + pub name: String, + #[serde(rename = "Type")] + pub media_type: MediaType, + pub id: String, + pub run_time_ticks: i64, + pub production_year: Option, + pub genres: Option>, + pub external_urls: Option>, + // Episode related + pub parent_index_number: Option, + pub index_number: Option, + pub index_number_end: Option, + pub series_name: Option, + pub series_id: Option, + // Audio related + pub artists: Option>, + pub extra_type: Option, + pub album_id: Option, + pub album: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct ExternalUrl { + pub name: String, + pub url: String, +} + +/// The type of the currently playing content. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MediaType { + /// If the content playing is a Movie. + Movie, + /// If the content playing is an Episode. + Episode, + /// If the content playing is a LiveTv. + LiveTv, + /// If the content playing is a Music. + Music, + /// If the content playing is a Book. + Book, + /// If the content playing is an Audio Book. + AudioBook, + /// If nothing is playing. + None, +} + +impl Serialize for MediaType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match *self { + MediaType::Movie => serializer.serialize_unit_variant("MediaType", 0, "Movie"), + MediaType::Episode => serializer.serialize_unit_variant("MediaType", 1, "Episode"), + MediaType::LiveTv => serializer.serialize_unit_variant("MediaType", 2, "LiveTv"), + MediaType::Music => serializer.serialize_unit_variant("MediaType", 3, "Music"), + MediaType::Book => serializer.serialize_unit_variant("MediaType", 4, "Book"), + MediaType::AudioBook => serializer.serialize_unit_variant("MediaType", 4, "AudioBook"), + MediaType::None => serializer.serialize_unit_variant("MediaType", 5, "None"), + } + } +} + +impl<'de> Deserialize<'de> for MediaType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_string(MediaTypeVisitor) + } +} + +struct MediaTypeVisitor; + +impl<'de> Visitor<'de> for MediaTypeVisitor { + type Value = MediaType; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string") + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + Ok(MediaType::from(v.to_lowercase())) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(MediaType::from(v.to_lowercase())) + } + + fn visit_borrowed_str(self, v: &'de str) -> Result + where + E: serde::de::Error, + { + Ok(MediaType::from(v.to_lowercase())) + } +} + +impl std::fmt::Display for MediaType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let res = match self { + MediaType::Episode => "Episode", + MediaType::LiveTv => "LiveTv", + MediaType::Movie => "Movie", + MediaType::Music => "Music", + MediaType::Book => "Book", + MediaType::AudioBook => "AudioBook", + MediaType::None => "None", + }; + write!(f, "{}", res) + } +} + +impl Default for MediaType { + fn default() -> Self { + Self::None + } +} + +impl From<&'static str> for MediaType { + fn from(value: &'static str) -> Self { + match value { + "episode" => Self::Episode, + "movie" => Self::Movie, + "music" | "audio" => Self::Music, + "livetv" | "tvchannel" => Self::LiveTv, + "book" => Self::Book, + "audiobook" => Self::AudioBook, + _ => Self::None, + } + } +} + +impl From for MediaType { + fn from(value: String) -> Self { + match value.as_str() { + "episode" => Self::Episode, + "movie" => Self::Movie, + "music" | "audio" => Self::Music, + "livetv" | "tvchannel" => Self::LiveTv, + "book" => Self::Book, + "audiobook" => Self::AudioBook, + _ => Self::None, + } + } +} + + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct PlayState { + pub is_paused: bool, + pub position_ticks: Option, + +} + +#[derive(Deserialize, Debug)] +pub struct Item { + pub name: Option, +} diff --git a/jellyfin-rpc/src/lib.rs b/jellyfin-rpc/src/lib.rs index 627d926..6aecbe9 100644 --- a/jellyfin-rpc/src/lib.rs +++ b/jellyfin-rpc/src/lib.rs @@ -1,60 +1,566 @@ -//! Backend for displaying jellyfin rich presence on discord - -/// Main module -pub mod core; -/// Useful imports -/// -/// Contains imports that most programs will be using. -pub mod prelude; -/// External connections -pub mod services; -pub use crate::core::error; -use colored::Colorize; -pub use core::rpc::setactivity; -pub use discord_rich_presence; -use discord_rich_presence::DiscordIpc; -use discord_rich_presence::DiscordIpcClient; -use log::info; -use retry::retry_with_index; -#[cfg(test)] -mod tests; - -/// Function for connecting to the Discord Ipc. -pub fn connect(rich_presence_client: &mut DiscordIpcClient) { - retry_with_index( - retry::delay::Exponential::from_millis(1000), - |current_try| { - info!( - "{} {}{}", - "Attempt".bold().truecolor(225, 69, 0), - current_try.to_string().bold().truecolor(225, 69, 0), - ": Trying to connect".bold().truecolor(225, 69, 0) - ); - match rich_presence_client.connect() { - Ok(result) => retry::OperationResult::Ok(result), - Err(err) => { - log::error!( - "{}\nError: {}", - "Failed to connect, retrying soon".red().bold(), - err - ); - retry::OperationResult::Retry(()) - } - } - }, - ) - .unwrap(); -} - -/// Built in reqwest::get() function, has an extra field to specify if the self signed cert should be accepted. -pub async fn get( - url: U, - self_signed_cert: bool, -) -> Result { - reqwest::Client::builder() - .danger_accept_invalid_certs(self_signed_cert) - .build()? - .get(url) - .send() - .await +use std::str::FromStr; +use discord_rich_presence::{activity::{Activity, Assets, Timestamps}, DiscordIpc, DiscordIpcClient}; +use discord_rich_presence::activity::Button as ActButton; +use jellyfin::{EndTime, Item, RawSession, Session}; +use log::debug; +use url::{ParseError, Url}; +pub use jellyfin::{MediaType, Button}; +pub use error::JfError; + +mod jellyfin; +mod external; +mod error; + +pub(crate) type JfResult = Result>; + +pub struct Client { + discord_ipc_client: DiscordIpcClient, + url: Url, + api_key: String, + usernames: Vec, + reqwest: reqwest::blocking::Client, + session: Option, + buttons: Option>, + episode_display_options: EpisodeDisplayOptions, + music_display_options: MusicDisplayOptions, + blacklist: Blacklist, + show_paused: bool, + show_images: bool, + imgur_options: ImgurOptions, + large_image_text: String, +} + +impl Client { + pub fn builder() -> ClientBuilder { + ClientBuilder::new() + } + + pub fn connect(&mut self) -> JfResult<()> { + self.discord_ipc_client.connect() + } + + pub fn reconnect(&mut self) -> JfResult<()> { + self.discord_ipc_client.reconnect() + } + + pub fn clear_activity(&mut self) -> JfResult<()> { + self.discord_ipc_client.clear_activity() + } + + pub fn set_activity(&mut self) -> JfResult { + self.get_session()?; + + if let Some(session) = &self.session { + if session.now_playing_item.media_type == MediaType::None { + return Err(Box::new(JfError::UnrecognizedMediaType)); + } + + if self.check_blacklist()? { + return Err(Box::new(JfError::ContentBlacklist)) + } + + let mut activity = Activity::new(); + + let mut image_url = Url::from_str("https://i.imgur.com/oX6vcds.png")?; + + if session.now_playing_item.media_type == MediaType::LiveTv { + image_url = Url::from_str("https://i.imgur.com/XxdHOqm.png")?; + } else if self.imgur_options.enabled { + if let Ok(imgur_url) = external::imgur::get_image(&self) { + image_url = imgur_url; + } else { + debug!("imgur::get_image() didnt return an image, using default..") + } + } else if self.show_images { + if let Ok(iu) = self.get_image() { + image_url = iu; + } else { + debug!("self.get_image() didnt return an image, using default..") + } + } + + let mut assets = Assets::new() + .large_image(image_url.as_str()); + + if !self.large_image_text.is_empty() { + assets = assets.large_text(&self.large_image_text); + } + + let mut timestamps = Timestamps::new(); + + match session.get_endtime()? { + EndTime::Some(end) => timestamps = timestamps.end(end), + EndTime::NoEndTime => (), + EndTime::Paused if self.show_paused => { + assets = assets + .small_image("https://i.imgur.com/wlHSvYy.png") + .small_text("Paused"); + }, + EndTime::Paused => return Ok(String::new()), + } + + let buttons: Vec