diff --git a/Cargo.lock b/Cargo.lock index f1d6cdd..ccdd952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -481,6 +481,15 @@ dependencies = [ "regex", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -2799,6 +2808,7 @@ dependencies = [ "async-std", "async-trait", "axum-test-helper", + "bincode", "bytes", "console-subscriber", "derive_more", @@ -2827,6 +2837,7 @@ dependencies = [ "serde_with", "strum", "strum_macros", + "tmdb-api", "tokio", "tonic 0.8.3", "tracing", @@ -2880,6 +2891,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", "winreg", ] @@ -3379,6 +3391,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3755,6 +3778,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tmdb-api" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00555d5350cf701229c57f3de60aad1fe3db53f579e7aedacb99456631aa15a2" +dependencies = [ + "async-trait", + "chrono", + "reqwest", + "serde", + "serde_repr", +] + [[package]] name = "tokio" version = "1.29.1" @@ -4355,6 +4391,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 5a2e398..a3a35cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ moka = { version = "0.11.2", features = ["future"] } tonic = {version = "0.8.0", features = ["tls", "tls-roots"]} async-recursion = "1.0.4" console-subscriber = "0.1.10" +tmdb-api = "0.5.0" +bincode = "1.3.3" [dev-dependencies] async-std = { version = "^1.12", features = ["attributes"] } diff --git a/Makefile b/Makefile index def570e..7f9320c 100644 --- a/Makefile +++ b/Makefile @@ -28,11 +28,11 @@ docker-run: # REPLEX_CACHE_TTL=0 REPLEX_HOST=https://46-4-30-217.01b0839de64b49138531cab1bf32f7c2.plex.direct:42405 REPLEX_NEWRELIC_API_KEY="eu01xx2d3c6a5e537373a8f8b52003b3FFFFNRAL" RUST_LOG="debug,replex=debug" cargo watch -x run -# run: -# REPLEX_ENABLE_CONSOLE=0 REPLEX_CACHE_TTL=0 REPLEX_HOST=https://46-4-30-217.01b0839de64b49138531cab1bf32f7c2.plex.direct:42405 RUST_LOG="debug" cargo watch -x run - run: - REPLEX_ENABLE_CONSOLE=0 REPLEX_CACHE_TTL=0 REPLEX_HOST=https://46-4-30-217.01b0839de64b49138531cab1bf32f7c2.plex.direct:42405 RUST_LOG="info" cargo run + REPLEX_TMDB_API_KEY=0d73e0cb91f39e670b0efa6913afbd58 REPLEX_ENABLE_CONSOLE=0 REPLEX_CACHE_TTL=0 REPLEX_HOST=https://46-4-30-217.01b0839de64b49138531cab1bf32f7c2.plex.direct:42405 RUST_LOG="info" cargo watch -x run + +# run: +# REPLEX_ENABLE_CONSOLE=0 REPLEX_CACHE_TTL=0 REPLEX_HOST=https://46-4-30-217.01b0839de64b49138531cab1bf32f7c2.plex.direct:42405 RUST_LOG="info" cargo run fix: diff --git a/README.md b/README.md index 03a9934..08eda7b 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,15 @@ Settings are set via [environment variables](https://kinsta.com/knowledgebase/wh | REPLEX_HOST | | Plex target host to proxy | | REPLEX_INCLUDE_WATCHED | false | If set to false, hide watched items. | | REPLEX_CACHE_TTL | 300 | Time to live for caches in seconds. Set to 0 to disable | +| REPLEX_TMDB_API_KEY | | Enables tmdb artwork for hero hubs instead of plex background artwork | ## hub style You can change the hub style to hero elements by setting the label "REPLEXHERO" on an collection. +Plex uses an items background for hero styles rows. Often these dont have any text or are not suitable for hero artwork in general. +You can use tmdb to automaticly load hero artwork by providing the env `REPLEX_TMDB_API_KEY`. This way you can keep your backgrounds and hero artwork seperated. + +see https://developer.themoviedb.org/docs/getting-started on how to get an api key. ## usage example diff --git a/src/cache.rs b/src/cache.rs index 56a9249..941bc1d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,5 +1,137 @@ use async_trait::async_trait; -use salvo::{cache::CacheIssuer, Request, Depot}; +use moka::{future::Cache, future::ConcurrentCacheExt, Expiry}; +use once_cell::sync::Lazy; +use salvo::{cache::CacheIssuer, Depot, Request}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use std::hash::Hash; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use std::error::Error; + +use crate::config::Config; + +// we close, this is a good example: https://github.com/getsentry/symbolicator/blob/170062d5bc7d4638a3e6af8a564cd881d798f1f0/crates/symbolicator-service/src/caching/memory.rs#L85 + +pub type CacheKey = String; +pub type CacheValue = (Expiration, Arc>); +// pub type CacheValue = Arc>; +pub type GlobalCacheType = Cache; + +pub(crate) static GLOBAL_CACHE: Lazy = Lazy::new(|| { + let expiry = CacheExpiry; + + // let store: GlobalCacheType = + CacheManager::new( + Cache::builder() + .max_capacity(10000) + .expire_after(expiry) + .build(), + ) +}); + +/// An enum to represent the expiration of a value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Expiration { + /// The value never expires. + Never, + /// Global TTL from the config + Global, +} + +impl Expiration { + /// Returns the duration of this expiration. + pub fn as_duration(&self) -> Option { + let config: Config = Config::figment().extract().unwrap(); + match self { + Expiration::Never => None, + Expiration::Global => Some(Duration::from_secs(config.cache_ttl)), + } + } +} + +/// An expiry that implements `moka::Expiry` trait. `Expiry` trait provides the +/// default implementations of three callback methods `expire_after_create`, +/// `expire_after_read`, and `expire_after_update`. +/// +/// In this example, we only override the `expire_after_create` method. +pub struct CacheExpiry; + +impl Expiry>)> for CacheExpiry { + /// Returns the duration of the expiration of the value that was just + /// created. + fn expire_after_create( + &self, + _key: &CacheKey, + value: &(Expiration, Arc>), + _current_time: Instant, + ) -> Option { + let duration = value.0.as_duration(); + duration + } +} + +#[derive(Clone)] +pub struct CacheManager { + /// The instance of `moka::future::Cache` + // pub store: Arc>>>, + // pub inner: S, + pub inner: GlobalCacheType, +} + +impl CacheManager { + /// Create a new manager from a pre-configured Cache + // pub fn new(store: Cache>>) -> Self { + pub fn new(cache: GlobalCacheType) -> Self { + Self { + inner: cache, // store: Arc::new(store), + } + } + /// Clears out the entire cache. + pub async fn clear(&self) -> anyhow::Result<()> { + self.inner.invalidate_all(); + self.inner.sync(); + Ok(()) + } + + pub async fn get(&self, cache_key: &str) -> Option + where + T: DeserializeOwned, + { + match self.inner.get(cache_key) { + Some(d) => { + let result: T = bincode::deserialize(&d.1).unwrap(); + Some(result) + }, + None => None, + } + } + + pub async fn insert( + &self, + cache_key: String, + v: V, + expires: Expiration, + ) -> anyhow::Result<()> + where + V: Serialize, + { + + let value = (expires, Arc::new(bincode::serialize(&v)?)); + // let bytes = bincode::serialize(&value)?; + self.inner.insert(cache_key, value).await; + self.inner.sync(); + Ok(()) + } + + pub async fn delete(&self, cache_key: &str) -> anyhow::Result<()> { + self.inner.invalidate(cache_key).await; + self.inner.sync(); + Ok(()) + } +} pub struct RequestIssuer { use_scheme: bool, @@ -7,7 +139,7 @@ pub struct RequestIssuer { use_path: bool, use_query: bool, use_method: bool, - use_token: bool + use_token: bool, } impl Default for RequestIssuer { fn default() -> Self { @@ -60,7 +192,11 @@ impl RequestIssuer { #[async_trait] impl CacheIssuer for RequestIssuer { type Key = String; - async fn issue(&self, req: &mut Request, _depot: &Depot) -> Option { + async fn issue( + &self, + req: &mut Request, + _depot: &Depot, + ) -> Option { let mut key = String::new(); if self.use_scheme { if let Some(scheme) = req.uri().scheme_str() { @@ -97,4 +233,4 @@ impl CacheIssuer for RequestIssuer { } Some(key) } -} \ No newline at end of file +} diff --git a/src/config.rs b/src/config.rs index 20236ab..aea20cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,7 +28,8 @@ pub struct Config { default = "default_as_false", deserialize_with = "figment::util::bool_from_str_or_int" )] - pub enable_console: bool + pub enable_console: bool, + pub tmdb_api_key: Option, } fn default_cache_ttl() -> u64 { diff --git a/src/lib.rs b/src/lib.rs index c426c74..deafe7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod transform; pub mod logging; pub mod cache; pub mod routes; +pub mod tmdb; #[cfg(test)] mod test_helpers; diff --git a/src/models.rs b/src/models.rs index d7de1ba..96e0949 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,10 +1,16 @@ use salvo::prelude::*; use std::str::FromStr; +use tmdb_api::movie::images::MovieImages; +use tmdb_api::prelude::Command; +use tmdb_api::tvshow::search::TVShowSearch; +use tmdb_api::Client; extern crate mime; +use crate::cache::GLOBAL_CACHE; use crate::config::*; use crate::plex_client::PlexClient; +use crate::tmdb::{TVShowImages, TMDB_CLIENT}; use crate::utils::*; use anyhow::Result; use async_trait::async_trait; @@ -99,6 +105,23 @@ where } } +#[derive( + Debug, + Serialize, + Deserialize, + Clone, + PartialEq, + Eq, + YaDeserialize, + YaSerialize, + Default, + PartialOrd, +)] +#[cfg_attr(feature = "tests_deny_unknown_fields", serde(deny_unknown_fields))] +pub struct Guid { + #[yaserde(attribute)] + id: String, +} #[derive( Debug, @@ -326,6 +349,9 @@ pub struct MetaData { #[yaserde(attribute)] #[serde(skip_serializing_if = "Option::is_none")] pub originally_available_at: Option, + #[serde(rename = "Guid", default, skip_serializing_if = "Vec::is_empty")] + #[yaserde(rename = "Guid", default, child)] + pub guids: Vec, } pub(crate) fn deserialize_option_string_from_number<'de, D>( @@ -338,6 +364,105 @@ where } impl MetaData { + pub async fn get_tmdb_banner(&self) -> Option { + if self.guids.is_empty() { + return None; + } + + let mut tmdb_id: Option = None; + for guid in self.guids.clone() { + if guid.id.starts_with("tmdb") { + let mut _tmdb_id = guid.id; + _tmdb_id = _tmdb_id.replace("tmdb://", ""); + tmdb_id = Some(_tmdb_id.parse().unwrap()); + break; + } + } + tmdb_id?; + + // TODO: Dry.... + // TODO: Foreign media often dont have "english" banners. Support native language banners + match self.r#type.as_str() { + "movie" => { + let cache_key = format!("tmdb:movie:{:?}:banner", tmdb_id); + let cached_result: Option> = + GLOBAL_CACHE.get(cache_key.as_str()).await; + + if let Some(i) = cached_result { + return i; + } + + let cmd = MovieImages::new(tmdb_id.unwrap()) + .with_language(Some("en".to_string())); + let banner: Option = + match cmd.execute(&TMDB_CLIENT).await { + Ok(res) => { + if !res.backdrops.is_empty() { + Some(format!( + "https://image.tmdb.org/t/p/original{}", + res.backdrops[0].file_path + )) + } else { + None + } + } + Err(_res) => { + // tracing::warn!("Could not find {:?}", res); + None + } + }; + + let _ = GLOBAL_CACHE + .insert( + cache_key, + banner.clone(), + crate::cache::Expiration::Never, + ) + .await; + banner + } + "show" => { + let cache_key = format!("tmdb:tv:{:?}:banner", tmdb_id); + let cached_result: Option> = + GLOBAL_CACHE.get(cache_key.as_str()).await; + + if let Some(i) = cached_result { + return i; + } + + let cmd = TVShowImages::new(tmdb_id.unwrap()) + .with_language(Some("en".to_string())); + let banner: Option = + match cmd.execute(&TMDB_CLIENT).await { + Ok(res) => { + if !res.backdrops.is_empty() { + Some(format!( + "https://image.tmdb.org/t/p/original{}", + res.backdrops[0].file_path + )) + } else { + None + } + } + Err(_res) => { + // tracing::warn!("Could not find {:?}", res); + None + } + }; + + let _ = GLOBAL_CACHE + .insert( + cache_key, + banner.clone(), + crate::cache::Expiration::Never, + ) + .await; + banner + } + _ => None, + } + } + pub fn children_mut(&mut self) -> &mut Vec { if !self.metadata.is_empty() { return &mut self.metadata; diff --git a/src/plex_client.rs b/src/plex_client.rs index 91c5a27..9624f42 100644 --- a/src/plex_client.rs +++ b/src/plex_client.rs @@ -137,6 +137,10 @@ impl PlexClient { if limit.is_some() { path = format!("{}&X-Plex-Container-Size={}", path, limit.unwrap()); } + + // we want guids for banners + path = format!("{}&includeGuids=1", path); + let resp = self.get(path).await.unwrap(); let container: MediaContainerWrapper = from_reqwest_response(resp).await.unwrap(); diff --git a/src/routes.rs b/src/routes.rs index 0caf895..49fc4e3 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -115,6 +115,13 @@ pub async fn get_hubs_promoted(req: &mut Request, res: &mut Response) { .to_string(), ); + // we want guids for banners + add_query_param_salvo( + req, + "includeGuids".to_string(), + "1".to_string(), + ); + // Hack, as the list could be smaller when removing watched items. So we request more. if let Some(original_count) = params.clone().count { add_query_param_salvo( @@ -134,6 +141,7 @@ pub async fn get_hubs_promoted(req: &mut Request, res: &mut Response) { .with_transform(HubChildrenLimitTransform { limit: params.clone().count.unwrap(), }) + .with_transform(TMDBArtTransform) .apply_to(&mut container) .await; res.render(container); @@ -154,6 +162,13 @@ pub async fn get_hubs_sections(req: &mut Request, res: &mut Response) { ); } + // we want guids for banners + add_query_param_salvo( + req, + "includeGuids".to_string(), + "1".to_string(), + ); + let upstream_res = plex_client.request(req).await.unwrap(); let mut container: MediaContainerWrapper = from_reqwest_response(upstream_res).await.unwrap(); @@ -163,6 +178,7 @@ pub async fn get_hubs_sections(req: &mut Request, res: &mut Response) { .with_transform(HubChildrenLimitTransform { limit: params.clone().count.unwrap(), }) + .with_transform(TMDBArtTransform) // .with_filter(CollectionHubPermissionFilter) .with_filter(WatchedFilter) .apply_to(&mut container) @@ -203,6 +219,7 @@ pub async fn get_collections_children( offset, limit, }) + .with_transform(TMDBArtTransform) .apply_to(&mut container) .await; res.render(container); // TODO: FIx XML diff --git a/src/tmdb.rs b/src/tmdb.rs new file mode 100644 index 0000000..6274525 --- /dev/null +++ b/src/tmdb.rs @@ -0,0 +1,62 @@ +use std::borrow::Cow; + +use tmdb_api::tvshow::search::TVShowSearch; +use tmdb_api::prelude::Command; +use tmdb_api::common::image::Image; +use tmdb_api::Client; +use once_cell::sync::Lazy; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::config::Config; + +pub(crate) static TMDB_CLIENT: Lazy = + Lazy::new(|| { + let config: Config = Config::figment().extract().unwrap(); + Client::new(config.tmdb_api_key.unwrap()) + }); + +#[derive(Clone, Debug, Default)] +pub struct TVShowImages { + /// ID of the movie + pub show_id: u64, + /// ISO 639-1 value to display translated data for the fields that support it. + pub language: Option, +} + +impl TVShowImages { + pub fn new(show_id: u64) -> Self { + Self { + show_id, + language: None, + } + } + + pub fn with_language(mut self, value: Option) -> Self { + self.language = value; + self + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TVShowImagesResult { + pub id: u64, + pub backdrops: Vec, + pub posters: Vec, + pub logos: Vec, +} + +impl Command for TVShowImages { + type Output = TVShowImagesResult; + + fn path(&self) -> Cow<'static, str> { + Cow::Owned(format!("/tv/{}/images", self.show_id)) + } + + fn params(&self) -> Vec<(&'static str, Cow<'_, str>)> { + if let Some(ref language) = self.language { + vec![("language", Cow::Borrowed(language))] + } else { + Vec::new() + } + } +} \ No newline at end of file diff --git a/src/transform.rs b/src/transform.rs index 5bf7521..be0991c 100644 --- a/src/transform.rs +++ b/src/transform.rs @@ -3,10 +3,12 @@ use async_recursion::async_recursion; use async_trait::async_trait; use futures_util::{ future::{self, join_all, LocalBoxFuture}, - stream::FuturesUnordered, + stream::{FuturesUnordered, FuturesOrdered}, StreamExt, }; use itertools::Itertools; use std::sync::Arc; +use tokio::task::JoinSet; +use tokio::time::Instant; #[async_trait] pub trait Transform: Send + Sync + 'static { @@ -245,14 +247,14 @@ impl TransformBuilder { item, self.plex_client.clone(), self.options.clone(), - ).await; - }; - }; - + ) + .await; + } + } - // future::join_all(futures).await; + // future::join_all(futures).await; - // dont use join as it needs ti be executed in order + // dont use join as it needs ti be executed in order // } @@ -381,9 +383,8 @@ impl Transform for HubStyleTransform { let mut collection_details = plex_client .clone() .get_cached( - plex_client.get_collection( - get_collection_id_from_hub(item), - ), + plex_client + .get_collection(get_collection_id_from_hub(item)), format!("collection:{}", item.key.clone()).to_string(), ) .await @@ -445,7 +446,7 @@ impl Transform for HubMixTransform { // we only process collection hubs if !hub.is_collection_hub() { new_hubs.push(hub.to_owned()); - continue + continue; } let p = new_hubs.iter().position(|v| v.title == hub.title); @@ -503,6 +504,7 @@ impl Transform for LibraryMixTransform { if !config.include_watched { c.media_container.children_mut().retain(|x| !x.is_watched()); } + // total_size += c.media_container.total_size.unwrap(); match children.is_empty() { false => { @@ -543,6 +545,71 @@ impl Transform for HubChildrenLimitTransform { } } +#[derive(Default, Debug)] +pub struct TMDBArtTransform; + +impl TMDBArtTransform { + pub async fn transform(&self, item: &mut MetaData) { + let banner = item.get_tmdb_banner().await; + if banner.is_some() { + item.art = banner; + } + } + pub async fn apply_tmdb_banner(&self, item: &mut MetaData) -> MetaData { + let banner = item.get_tmdb_banner().await; + if banner.is_some() { + item.art = banner; + } + item.to_owned() + } +} + +#[async_trait] +impl Transform for TMDBArtTransform { + async fn transform_metadata( + &self, + item: &mut MetaData, + plex_client: PlexClient, + options: PlexParams, + ) { + let config: Config = Config::figment().extract().unwrap(); + + // let bla = async move |item| { + // let banner = item.get_tmdb_banner().await; + // if banner.is_some() { + // item.art = banner; + // } + // item.to_owned() + // } + + if config.tmdb_api_key.is_some() { + if item.is_hub() && item.style.clone().unwrap() == "hero" { + // let mut children: Vec = vec![]; + + let mut futures = FuturesOrdered::new(); + for child in item.children() { + futures.push_back(async move { + let mut c = child.clone(); + let banner = child.get_tmdb_banner().await; + if banner.is_some() { + c.art = banner; + } + return c + }); + } + // let now = Instant::now(); + + let children: Vec = futures.collect().await; + item.set_children(children); + + } else { + // keep this blocking for now. AS its loaded in the background + self.transform(item).await; + } + } + } +} + #[derive(Default)] pub struct WatchedFilter;