#[cfg(feature = "cache")]
use std::sync::Arc;
#[cfg(feature = "cache")]
use serde::Serialize;
#[cfg(feature = "cache")]
use serde::de::DeserializeOwned;
#[cfg(feature = "cache")]
use crate::cache::{CacheBackend, CacheError, CacheKey, CacheTtlConfig, MediaType, SqliteCache};
#[cfg(feature = "anilist")]
use crate::providers::anilist::{AniListClient, AniListConfig};
#[cfg(feature = "tmdb")]
use crate::providers::tmdb::{TmdbClient, TmdbConfig};
mod detail;
mod discovery;
mod recommendation;
mod search;
mod season;
mod watch_providers;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum CameoClientError {
#[error("no providers configured")]
NoProviders,
#[cfg(feature = "tmdb")]
#[error(transparent)]
Tmdb(#[from] crate::providers::tmdb::TmdbError),
#[cfg(feature = "anilist")]
#[error(transparent)]
AniList(#[from] crate::providers::anilist::AniListError),
#[cfg(feature = "cache")]
#[error("cache error: {0}")]
Cache(#[from] CacheError),
}
#[cfg(feature = "cache")]
struct Cache {
backend: Arc<dyn CacheBackend>,
ttl: CacheTtlConfig,
inflight_tx: Arc<tokio::sync::watch::Sender<usize>>,
inflight_rx: tokio::sync::watch::Receiver<usize>,
}
#[cfg(feature = "cache")]
impl Cache {
fn new(backend: Arc<dyn CacheBackend>, ttl: CacheTtlConfig) -> Self {
let (inflight_tx, inflight_rx) = tokio::sync::watch::channel(0_usize);
Self {
backend,
ttl,
inflight_tx: Arc::new(inflight_tx),
inflight_rx,
}
}
async fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
match self.backend.get(key).await {
Ok(Some(v)) => serde_json::from_value(v).ok(),
_ => None,
}
}
fn set<T: Serialize>(&self, key: CacheKey, value: &T, ttl: std::time::Duration) {
match serde_json::to_value(value) {
Ok(v) => {
self.inflight_tx.send_modify(|n| *n += 1);
let backend = Arc::clone(&self.backend);
let tx = Arc::clone(&self.inflight_tx);
tokio::spawn(async move {
if let Err(e) = backend.set(key, v, ttl).await {
tracing::warn!(error = %e, "cache write failed");
}
tx.send_modify(|n| *n -= 1);
});
}
Err(e) => {
tracing::warn!(error = %e, "cache serialization failed");
}
}
}
async fn wait_for_writes(&self) {
let _ = self.inflight_rx.clone().wait_for(|&n| n == 0).await;
}
}
#[derive(Default)]
pub struct CameoClientBuilder {
#[cfg(feature = "tmdb")]
tmdb_config: Option<TmdbConfig>,
#[cfg(feature = "anilist")]
anilist_config: Option<AniListConfig>,
#[cfg(feature = "cache")]
cache_backend: Option<Arc<dyn CacheBackend>>,
#[cfg(feature = "cache")]
cache_ttl: Option<CacheTtlConfig>,
}
impl std::fmt::Debug for CameoClientBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("CameoClientBuilder");
#[cfg(feature = "tmdb")]
d.field("tmdb_config", &self.tmdb_config);
#[cfg(feature = "anilist")]
d.field("anilist_config", &self.anilist_config);
#[cfg(feature = "cache")]
d.field("cache_backend", &self.cache_backend.as_ref().map(|_| ".."));
#[cfg(feature = "cache")]
d.field("cache_ttl", &self.cache_ttl);
d.finish()
}
}
impl CameoClientBuilder {
#[cfg(feature = "tmdb")]
pub fn with_tmdb(mut self, config: TmdbConfig) -> Self {
self.tmdb_config = Some(config);
self
}
#[cfg(feature = "anilist")]
pub fn with_anilist(mut self, config: AniListConfig) -> Self {
self.anilist_config = Some(config);
self
}
#[cfg(feature = "cache")]
pub fn with_cache(self) -> Self {
let path = dirs::cache_dir()
.map(|d| d.join("cameo").join("cache.db"))
.unwrap_or_else(|| std::env::temp_dir().join("cameo_cache.db"));
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match SqliteCache::new(&path) {
Ok(backend) => self.with_cache_backend(Arc::new(backend)),
Err(_) => {
match SqliteCache::in_memory() {
Ok(backend) => self.with_cache_backend(Arc::new(backend)),
Err(_) => self,
}
}
}
}
#[cfg(feature = "cache")]
pub fn with_cache_backend(mut self, backend: Arc<dyn CacheBackend>) -> Self {
self.cache_backend = Some(backend);
self
}
#[cfg(feature = "cache")]
pub fn with_cache_ttl(mut self, ttl: CacheTtlConfig) -> Self {
self.cache_ttl = Some(ttl);
self
}
pub fn build(self) -> Result<CameoClient, CameoClientError> {
#[cfg(feature = "tmdb")]
let tmdb = self
.tmdb_config
.map(TmdbClient::new)
.transpose()
.map_err(CameoClientError::Tmdb)?;
#[cfg(not(feature = "tmdb"))]
let tmdb: Option<()> = None;
#[cfg(feature = "anilist")]
let anilist = self
.anilist_config
.map(AniListClient::new)
.transpose()
.map_err(CameoClientError::AniList)?;
#[cfg(not(feature = "anilist"))]
let anilist: Option<()> = None;
if tmdb.is_none() && anilist.is_none() {
return Err(CameoClientError::NoProviders);
}
#[cfg(feature = "cache")]
let cache = self
.cache_backend
.map(|backend| Cache::new(backend, self.cache_ttl.unwrap_or_default()));
Ok(CameoClient {
#[cfg(feature = "tmdb")]
tmdb,
#[cfg(feature = "anilist")]
anilist,
#[cfg(feature = "cache")]
cache,
})
}
}
pub struct CameoClient {
#[cfg(feature = "tmdb")]
tmdb: Option<TmdbClient>,
#[cfg(feature = "anilist")]
anilist: Option<AniListClient>,
#[cfg(feature = "cache")]
cache: Option<Cache>,
}
impl std::fmt::Debug for CameoClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("CameoClient");
#[cfg(feature = "tmdb")]
d.field("tmdb", &self.tmdb);
#[cfg(feature = "anilist")]
d.field("anilist", &self.anilist);
#[cfg(feature = "cache")]
d.field("cache", &self.cache.as_ref().map(|_| ".."));
d.finish()
}
}
impl CameoClient {
pub fn builder() -> CameoClientBuilder {
CameoClientBuilder::default()
}
#[cfg(feature = "tmdb")]
pub fn tmdb(&self) -> Option<&TmdbClient> {
self.tmdb.as_ref()
}
#[cfg(feature = "anilist")]
pub fn anilist(&self) -> Option<&AniListClient> {
self.anilist.as_ref()
}
#[cfg(feature = "cache")]
pub async fn cached_movie(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedMovie> {
use crate::unified::models::UnifiedMovieDetails;
let cache = self.cache.as_ref()?;
let item_key = CacheKey::Item {
media_type: MediaType::Movie,
provider_id: provider_id.to_string(),
};
if let Some(m) = cache
.get::<crate::unified::models::UnifiedMovie>(&item_key)
.await
{
return Some(m);
}
let detail_key = CacheKey::Detail {
media_type: MediaType::Movie,
provider_id: provider_id.to_string(),
};
cache
.get::<UnifiedMovieDetails>(&detail_key)
.await
.map(|d| d.movie)
}
#[cfg(feature = "cache")]
pub async fn cached_movie_details(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedMovieDetails> {
let cache = self.cache.as_ref()?;
cache
.get(&CacheKey::Detail {
media_type: MediaType::Movie,
provider_id: provider_id.to_string(),
})
.await
}
#[cfg(feature = "cache")]
pub async fn cached_tv_show(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedTvShow> {
use crate::unified::models::UnifiedTvShowDetails;
let cache = self.cache.as_ref()?;
let item_key = CacheKey::Item {
media_type: MediaType::TvShow,
provider_id: provider_id.to_string(),
};
if let Some(t) = cache
.get::<crate::unified::models::UnifiedTvShow>(&item_key)
.await
{
return Some(t);
}
let detail_key = CacheKey::Detail {
media_type: MediaType::TvShow,
provider_id: provider_id.to_string(),
};
cache
.get::<UnifiedTvShowDetails>(&detail_key)
.await
.map(|d| d.show)
}
#[cfg(feature = "cache")]
pub async fn cached_tv_show_details(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedTvShowDetails> {
let cache = self.cache.as_ref()?;
cache
.get(&CacheKey::Detail {
media_type: MediaType::TvShow,
provider_id: provider_id.to_string(),
})
.await
}
#[cfg(feature = "cache")]
pub async fn cached_person(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedPerson> {
use crate::unified::models::UnifiedPersonDetails;
let cache = self.cache.as_ref()?;
let item_key = CacheKey::Item {
media_type: MediaType::Person,
provider_id: provider_id.to_string(),
};
if let Some(p) = cache
.get::<crate::unified::models::UnifiedPerson>(&item_key)
.await
{
return Some(p);
}
let detail_key = CacheKey::Detail {
media_type: MediaType::Person,
provider_id: provider_id.to_string(),
};
cache
.get::<UnifiedPersonDetails>(&detail_key)
.await
.map(|d| d.person)
}
#[cfg(feature = "cache")]
pub async fn cached_person_details(
&self,
provider_id: &str,
) -> Option<crate::unified::models::UnifiedPersonDetails> {
let cache = self.cache.as_ref()?;
cache
.get(&CacheKey::Detail {
media_type: MediaType::Person,
provider_id: provider_id.to_string(),
})
.await
}
#[cfg(feature = "cache")]
pub async fn invalidate_cached(&self, provider_id: &str) {
let Some(cache) = self.cache.as_ref() else {
return;
};
for mt in [MediaType::Movie, MediaType::TvShow, MediaType::Person] {
let pid = provider_id.to_string();
let _ = cache
.backend
.invalidate(&CacheKey::Detail {
media_type: mt,
provider_id: pid.clone(),
})
.await;
let _ = cache
.backend
.invalidate(&CacheKey::Item {
media_type: mt,
provider_id: pid,
})
.await;
}
}
#[cfg(feature = "cache")]
pub async fn clear_cache(&self) {
if let Some(cache) = self.cache.as_ref() {
let _ = cache.backend.clear().await;
}
}
#[cfg(feature = "cache")]
pub async fn flush_cache_writes(&self) {
if let Some(cache) = self.cache.as_ref() {
cache.wait_for_writes().await;
}
}
}