Update dependencies and lints

This commit is contained in:
2026-09-07 22:31:04 -07:00
parent b518d762f1
commit 9c288d051c
79 changed files with 3473 additions and 2923 deletions
+8 -5
View File
@@ -1,14 +1,16 @@
[package]
name = "flix-tmdb"
version = "0.0.20"
license-file.workspace = true
version = "0.1.0"
description = "Clients and models for fetching data from TMDB"
repository = "https://github.com/QuantumShade/flix"
categories = []
repository = "https://git.skrundz.dev/quantumshade/flix"
keywords = ["flix"]
categories = ["multimedia"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
@@ -32,6 +34,7 @@ sea-orm = { workspace = true, optional = true }
[dev-dependencies]
serde_test = { workspace = true }
tempfile = { workspace = true }
[features]
default = []
+23 -11
View File
@@ -1,6 +1,6 @@
//! Collections API
//! Collections API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Collections API client
/// TMDB Collections API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the collection refered to by ID
/// Fetch the details of the collection refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<CollectionId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Episodes API
//! Episodes API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use flix_model::numbers::{EpisodeNumber, SeasonNumber};
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Episodes API client
/// TMDB Episodes API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the episode refered to by ID, season number and episode number
/// Fetch the details of the episode refered to by ID, season number and episode number.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+49 -35
View File
@@ -1,6 +1,5 @@
//! TMDB API clients
//! TMDB API clients.
use core::ops::Deref;
use core::time::Duration;
use std::sync::RwLock;
@@ -17,20 +16,26 @@ pub mod movies;
pub mod seasons;
pub mod shows;
/// A generic error wrapping Url and Reqwest errors
/// A generic error wrapping Url and Reqwest errors.
#[derive(Debug, thiserror::Error)]
#[expect(clippy::error_impl_error, reason = "Error is a good name here")]
#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
pub enum Error {
/// Url error wrapper
/// Url error wrapper.
#[error("url parse error: {0}")]
Url(#[from] url::ParseError),
/// Reqwest error wrapper
/// Reqwest error wrapper.
#[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
/// Json error wrapper
/// Json error wrapper.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
/// Turn a [Config] and `path` into a [Request].
///
/// # Errors
/// See [Error] for failure modes.
fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<Request, Error> {
let url = config.base_url.join(path)?;
@@ -38,7 +43,7 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
header::AUTHORIZATION,
format!("Bearer {}", config.bearer_token),
);
if let Some(ref user_agent) = config.user_agent {
if let Some(user_agent) = &config.user_agent {
builder = builder.header(header::USER_AGENT, user_agent);
}
if let Some(language) = language {
@@ -48,22 +53,22 @@ fn make_request(config: &Config, path: &str, language: Option<&str>) -> Result<R
Ok(builder.build()?)
}
/// Execute a [Request] and deserialize the response.
///
/// # Errors
/// See [Error] for failure modes.
async fn exec_request<T: DeserializeOwned>(
config: &Config,
cache: &dyn Cache,
policy: &RwLock<CachePolicy>,
request: Request,
) -> Result<T, Error> {
let (read_cache, write_cache) = if let Ok(guard) = policy.read() {
match guard.deref() {
CachePolicy::None => (None, None),
CachePolicy::Full => (Some(cache), Some(cache)),
CachePolicy::Read => (Some(cache), None),
CachePolicy::Update => (None, Some(cache)),
}
} else {
(None, None)
};
let (read_cache, write_cache) = policy.read().map_or((None, None), |guard| match *guard {
CachePolicy::None => (None, None),
CachePolicy::Full => (Some(cache), Some(cache)),
CachePolicy::Read => (Some(cache), None),
CachePolicy::Update => (None, Some(cache)),
});
let path = request.url().path().to_owned();
@@ -73,24 +78,23 @@ async fn exec_request<T: DeserializeOwned>(
response = cache.get(&path);
}
let needs_cache_write = response.is_none();
let response = match response {
Some(response) => response,
None => {
config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
config
.client
.execute(request)
.await?
.error_for_status()?
.bytes()
.await?
}
let response = if let Some(response) = response {
response
} else {
config
.limiter
.until_ready_with_jitter(Jitter::new(
Duration::from_millis(0),
Duration::from_millis(50),
))
.await;
config
.client
.execute(request)
.await?
.error_for_status()?
.bytes()
.await?
};
// write to the cache if needed
@@ -102,3 +106,13 @@ async fn exec_request<T: DeserializeOwned>(
Ok(serde_json::from_slice(&response)?)
}
#[cfg(test)]
mod tests {
use super::Error;
#[test]
fn use_types() {
drop(Error::Url(url::ParseError::Overflow));
}
}
+23 -11
View File
@@ -1,6 +1,6 @@
//! Movies API
//! Movies API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Movies API client
/// TMDB Movies API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the movie refered to by ID
/// Fetch the details of the movie refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<MovieId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Seasons API
//! Seasons API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use flix_model::numbers::SeasonNumber;
@@ -12,26 +12,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Seasons API client
/// TMDB Seasons API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the season refered to by ID and season number
/// Fetch the details of the season refered to by ID and season number.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+23 -11
View File
@@ -1,6 +1,6 @@
//! Shows API
//! Shows API.
use std::rc::Rc;
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::api::exec_request;
@@ -10,26 +10,38 @@ use crate::{Cache, CachePolicy, Config};
use super::{Error, make_request};
/// TMDB Shows API client
/// TMDB Shows API client.
#[derive(Debug)]
pub struct Client {
config: Rc<Config>,
cache: Rc<dyn Cache>,
policy: Rc<RwLock<CachePolicy>>,
/// The client configuration.
config: Arc<Config>,
/// The request cache.
cache: Arc<dyn Cache>,
/// The cache policy to use for requests.
policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Rc<Config>, cache: Rc<dyn Cache>, policy: Rc<RwLock<CachePolicy>>) -> Self {
/// Create a new client with the given configuration.
#[inline]
pub fn new(
config: Arc<Config>,
cache: Arc<dyn Cache>,
policy: Arc<RwLock<CachePolicy>>,
) -> Self {
Self {
config,
cache,
policy,
}
}
}
impl Client {
/// Fetch the details of the show refered to by ID
/// Fetch the details of the show refered to by ID.
///
/// # Errors
/// See [Error] for failure modes.
#[inline]
#[expect(clippy::impl_trait_in_params, reason = "turbofish is not expected")]
pub async fn get_details(
&self,
id: impl Into<ShowId>,
+35 -22
View File
@@ -1,58 +1,72 @@
//! Caching related types.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use redb::{Database, DatabaseError, ReadableDatabase, TableDefinition};
use redb::{Database, DatabaseError, ReadableDatabase as _, TableDefinition};
/// The client cache policy
/// The client cache policy.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum CachePolicy {
/// Do not use a cache
/// Do not use a cache.
None,
/// Use and update the cache
/// Use and update the cache.
Full,
/// Use the cache but don't update it
/// Use the cache but don't update it.
Read,
/// Ignore the cache but update it
/// Ignore the cache but update it.
Update,
}
/// The trait representing a caching backend
pub trait Cache {
/// Get a cached value, or None
/// The trait representing a caching backend.
pub trait Cache: core::fmt::Debug + Send + Sync {
/// Get a cached value, or None.
fn get(&self, query: &str) -> Option<Bytes>;
/// Set a value in the cache
/// Set a value in the cache.
fn set(&self, query: &str, response: &Bytes);
}
const TABLE: TableDefinition<&str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
/// The [`TableDefinition`] for TMDB response data.
const TABLE: TableDefinition<'_, &str, (u64, &[u8])> = TableDefinition::new("tmdb_responses");
/// A [Cache] implementation using [redb] as the backend
/// A [Cache] implementation using [redb] as the backend.
#[derive(Debug)]
pub struct RedbCache {
/// The underlying database.
db: Database,
}
impl RedbCache {
/// Create/open a [redb] database at the path
/// Create/open a [redb] database at the path.
///
/// # Errors
/// Fails if creating/opening the database returns an error.
#[inline]
pub fn new(path: &Path) -> Result<Self, DatabaseError> {
Ok(Self {
db: Database::create(path)?,
})
}
/// Helper function allowing for `.ok()?`
/// Helper function allowing for `.ok()?`.
fn write(&self, timestamp: u64, query: &str, response: &Bytes) -> Option<()> {
let write_txn = self.db.begin_write().ok()?;
{
let mut table = write_txn.open_table(TABLE).ok()?;
table
.insert(query, (timestamp, response.iter().as_slice()))
.ok()?;
drop(
table
.insert(query, (timestamp, response.iter().as_slice()))
.ok()?,
);
}
write_txn.commit().ok()
}
}
impl Cache for RedbCache {
#[inline]
fn get(&self, query: &str) -> Option<Bytes> {
let read_txn = self.db.begin_read().ok()?;
let table = read_txn.open_table(TABLE).ok()?;
@@ -62,8 +76,7 @@ impl Cache for RedbCache {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
.map_or(0, |d| d.as_secs());
if now.saturating_sub(timestamp) >= 60 * 60 * 24 * 30 * 6 {
None
@@ -72,12 +85,12 @@ impl Cache for RedbCache {
}
}
#[inline]
fn set(&self, query: &str, response: &Bytes) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
.map_or(0, |d| d.as_secs());
self.write(now, query, response);
_ = self.write(now, query, response);
}
}
+57 -30
View File
@@ -1,44 +1,63 @@
use std::rc::Rc;
//! TMDB client.
use alloc::sync::Arc;
use std::sync::RwLock;
use crate::{Cache, CachePolicy, Config, api};
/// The primary client that references all other clients
/// The primary client that references all other clients.
#[derive(Debug)]
pub struct Client {
/// The collection API client.
collections: api::collections::Client,
/// The movie API client.
movies: api::movies::Client,
/// The show API client.
shows: api::shows::Client,
/// The season API client.
seasons: api::seasons::Client,
/// The episode API client.
episodes: api::episodes::Client,
cache_policy: Rc<RwLock<CachePolicy>>,
/// The cache policy to use for all clients.
cache_policy: Arc<RwLock<CachePolicy>>,
}
impl Client {
/// Create a new client with the given configuration
pub fn new(config: Config, cache: Rc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let config = Rc::new(config);
let cache_policy = Rc::new(RwLock::new(cache_policy));
/// Create a new client with the given configuration.
#[inline]
pub fn new(config: Config, cache: Arc<dyn Cache>, cache_policy: CachePolicy) -> Self {
let config = Arc::new(config);
let cache_policy = Arc::new(RwLock::new(cache_policy));
Self {
collections: api::collections::Client::new(
config.clone(),
cache.clone(),
cache_policy.clone(),
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
movies: api::movies::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
shows: api::shows::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
seasons: api::seasons::Client::new(config.clone(), cache.clone(), cache_policy.clone()),
episodes: api::episodes::Client::new(
config.clone(),
cache.clone(),
cache_policy.clone(),
movies: api::movies::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
shows: api::shows::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
seasons: api::seasons::Client::new(
Arc::clone(&config),
Arc::clone(&cache),
Arc::clone(&cache_policy),
),
episodes: api::episodes::Client::new(config, cache, Arc::clone(&cache_policy)),
cache_policy,
}
}
/// Modify the [CachePolicy]
/// Modify the [`CachePolicy`].
#[inline]
pub fn set_cache_policy(&self, new_policy: CachePolicy) {
match self.cache_policy.write() {
Ok(mut policy) => *policy = new_policy,
@@ -48,31 +67,39 @@ impl Client {
}
}
}
}
impl Client {
/// Access the Collections API
pub fn collections(&self) -> &api::collections::Client {
/// Access the Collections API.
#[inline]
#[must_use]
pub const fn collections(&self) -> &api::collections::Client {
&self.collections
}
/// Access the Movies API
pub fn movies(&self) -> &api::movies::Client {
/// Access the Movies API.
#[inline]
#[must_use]
pub const fn movies(&self) -> &api::movies::Client {
&self.movies
}
/// Access the Shows API
pub fn shows(&self) -> &api::shows::Client {
/// Access the Shows API.
#[inline]
#[must_use]
pub const fn shows(&self) -> &api::shows::Client {
&self.shows
}
/// Access the Seasons API
pub fn seasons(&self) -> &api::seasons::Client {
/// Access the Seasons API.
#[inline]
#[must_use]
pub const fn seasons(&self) -> &api::seasons::Client {
&self.seasons
}
/// Access the Episodes API
pub fn episodes(&self) -> &api::episodes::Client {
/// Access the Episodes API.
#[inline]
#[must_use]
pub const fn episodes(&self) -> &api::episodes::Client {
&self.episodes
}
}
+14 -8
View File
@@ -1,3 +1,5 @@
//! Client configuration.
use governor::clock::MonotonicClock;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
@@ -5,27 +7,31 @@ use nonzero_ext::nonzero;
use url::Url;
use url_macro::url;
/// The client configuration
/// The client configuration.
#[derive(Debug)]
#[expect(clippy::exhaustive_structs, reason = "must be constructed in full")]
pub struct Config {
/// The base URL of the API
/// The base URL of the API.
pub base_url: Url,
/// The reqwest client that is used for every request
/// The reqwest client that is used for every request.
pub client: reqwest::Client,
/// The rate limiter to use for the client
/// The rate limiter to use for the client.
pub limiter: RateLimiter<NotKeyed, InMemoryState, MonotonicClock>,
/// The bearer token for readonly access to the API
/// The bearer token for readonly access to the API.
pub bearer_token: String,
/// An optional user agent string to provide to the API
/// An optional user agent string to provide to the API.
pub user_agent: Option<String>,
}
impl Config {
/// Create a new configuration using the provided bearer token
/// Create a new configuration using the provided bearer token.
#[inline]
#[must_use]
pub fn new(bearer_token: String) -> Self {
Self {
base_url: url!("https://api.themoviedb.org"),
client: reqwest::Client::new(),
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30u32))),
limiter: RateLimiter::direct(Quota::per_second(nonzero!(30_u32))),
bearer_token,
user_agent: None,
}
+20 -1
View File
@@ -1,7 +1,9 @@
//! flix-tmdb provides clients and models for fetching data from TMDB
//! flix-tmdb provides clients and models for fetching data from TMDB.
#![cfg_attr(docsrs, feature(doc_cfg))]
extern crate alloc;
pub mod api;
pub mod model;
@@ -13,3 +15,20 @@ pub use client::Client;
mod config;
pub use config::Config;
#[cfg(test)]
mod tests {
use alloc::sync::Arc;
use super::{CachePolicy, Client, Config, RedbCache};
#[cfg(not(miri))]
#[test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
fn use_types() {
let tmp = tempfile::tempdir().expect("temporary directory exists");
let config = Config::new(String::new());
let cache = Arc::new(RedbCache::new(&tmp.path().join("cache.redb")).expect("redb"));
drop(Client::new(config, cache, CachePolicy::Full));
}
}
+31 -8
View File
@@ -1,25 +1,48 @@
//! TMDB collection model.
use super::id::{CollectionId, MovieId};
/// A deserialized Collection from the TMDB API
/// A deserialized Collection from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Collection {
/// The collection's TMDB ID
/// The collection's TMDB ID.
pub id: CollectionId,
/// The collection's title
/// The collection's title.
#[serde(rename = "name")]
pub title: String,
/// The collection's overview
/// The collection's overview.
pub overview: String,
/// The list of movies that are part of this collection
/// The list of movies that are part of this collection.
#[serde(rename = "parts")]
pub movies: Vec<Item>,
}
/// A deserialized collection item from the TMDB API
/// A deserialized collection item from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Item {
/// The movie's TMDB ID
/// The movie's TMDB ID.
pub id: MovieId,
/// The movie's title
/// The movie's title.
pub title: String,
}
#[cfg(test)]
mod tests {
use super::{Collection, CollectionId, Item, MovieId};
#[test]
fn use_types() {
drop(Collection {
id: CollectionId::from_raw(0),
title: String::new(),
overview: String::new(),
movies: Vec::new(),
});
drop(Item {
id: MovieId::from_raw(0),
title: String::new(),
});
}
}
+31 -7
View File
@@ -1,3 +1,5 @@
//! TMDB episode model.
use core::time::Duration;
use flix_model::numbers::EpisodeNumber;
@@ -7,22 +9,44 @@ use url::Url;
use super::{duration_from_minutes, still_url_from_path};
/// A deserialized Episode from the TMDB API
/// A deserialized Episode from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Episode {
/// The episode's number
/// The episode's number.
pub episode_number: EpisodeNumber,
/// The episode's title
/// The episode's title.
#[serde(rename = "name")]
pub title: String,
/// The episode's overview
/// The episode's overview.
pub overview: String,
/// The episode's air date
/// The episode's air date.
pub air_date: NaiveDate,
/// The episode's runtime
/// The episode's runtime.
#[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration,
/// The episode's still path
/// The episode's still path.
#[serde(deserialize_with = "still_url_from_path")]
pub still_path: Option<Url>,
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use chrono::NaiveDate;
use super::{Episode, EpisodeNumber};
#[test]
fn use_types() {
drop(Episode {
episode_number: EpisodeNumber::new(0),
title: String::new(),
overview: String::new(),
air_date: NaiveDate::default(),
runtime: Duration::default(),
still_path: None,
});
}
}
+63 -24
View File
@@ -1,4 +1,6 @@
//! Typed TMDB IDs
//! Typed TMDB IDs.
#![expect(clippy::module_name_repetitions, reason = "ID types end with Id")]
use core::cmp::Ordering;
use core::fmt;
@@ -10,21 +12,25 @@ use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
#[cfg(feature = "sea-orm")]
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation used by TMDB
/// The internal representation used by TMDB.
pub type TmdbRepr = u32;
/// An opaque type representing a TMDB ID
/// An opaque type representing a TMDB ID.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub struct Id<T> {
/// Internal ID representation.
id: TmdbRepr,
/// Marker to indicate an invariant T.
#[serde(skip_serializing, default)]
_phantom: PhantomData<T>,
_phantom: PhantomData<fn(T) -> T>,
}
// Manual implementation since `T: Clone` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Clone for Id<T> {
#[inline]
fn clone(&self) -> Self {
*self
}
@@ -34,31 +40,40 @@ impl<T> Clone for Id<T> {
impl<T> Copy for Id<T> {}
// Manual implementation since `T: PartialEq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialEq for Id<T> {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
// Manual implementation since `T: Eq` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Eq for Id<T> {}
// Manual implementation since `T: PartialOrd` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialOrd for Id<T> {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
// Manual implementation since `T: Ord` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Ord for Id<T> {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
// Manual implementation since `T: Hash` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Hash for Id<T> {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
@@ -66,6 +81,8 @@ impl<T> Hash for Id<T> {
impl<T> Id<T> {
/// Allows the conversion from a raw value to [Id], though the use is discouraged.
#[inline]
#[must_use]
pub fn from_raw(raw: TmdbRepr) -> Self {
Self {
id: raw,
@@ -74,13 +91,16 @@ impl<T> Id<T> {
}
/// Allows extracting the raw value, though the use is discouraged.
pub fn into_raw(self) -> TmdbRepr {
#[inline]
#[must_use]
pub const fn into_raw(self) -> TmdbRepr {
self.id
}
}
impl<T> fmt::Debug for Id<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Id")
.field("T", &core::any::type_name::<T>())
.field("id", &self.id)
@@ -89,7 +109,9 @@ impl<T> fmt::Debug for Id<T> {
}
#[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> ValueType for Id<T> {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<TmdbRepr as ValueType>::try_from(v).map(|id| Self {
id,
@@ -97,14 +119,17 @@ impl<T> ValueType for Id<T> {
})
}
#[inline]
fn type_name() -> String {
format!("Id<{}>", &core::any::type_name::<T>())
format!("Id<{}>", core::any::type_name::<T>())
}
#[inline]
fn array_type() -> ArrayType {
TmdbRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
TmdbRepr::column_type()
}
@@ -112,13 +137,16 @@ impl<T> ValueType for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> From<Id<T>> for Value {
#[inline]
fn from(value: Id<T>) -> Self {
value.id.into()
}
}
#[cfg(feature = "sea-orm")]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> TryGetable for Id<T> {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
TmdbRepr::try_get_by(res, index).map(|id| Self {
id,
@@ -129,6 +157,7 @@ impl<T> TryGetable for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> TryFromU64 for Id<T> {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
TmdbRepr::try_from_u64(n).map(|id| Self {
id,
@@ -139,6 +168,7 @@ impl<T> TryFromU64 for Id<T> {
#[cfg(feature = "sea-orm")]
impl<T> Nullable for Id<T> {
#[inline]
fn null() -> Value {
TmdbRepr::null()
}
@@ -146,34 +176,34 @@ impl<T> Nullable for Id<T> {
#[cfg(test)]
mod tests {
#[test]
#[cfg(feature = "sea-orm")]
fn test_sea_orm() {
#[expect(dead_code, reason = "structs test derive macros")]
mod test_seaorm {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::Id;
use super::super::Id;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ids")]
pub struct Model {
#[expect(clippy::use_self, reason = "derive macros cause Self to be invalid")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>,
nullable: Option<Id<Model>>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[allow(dead_code)]
#[derive(Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
enum Relation {}
}
#[test]
fn test_serde() {
fn serde() {
use super::Id;
let id: Id<()> = Id::from_raw(1234);
@@ -181,61 +211,70 @@ mod tests {
}
}
/// Type alias for the raw ID representation
pub use self::TmdbRepr as RawId;
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Collection {}
/// Type alias for a collection ID
/// Type alias for a collection ID.
pub type CollectionId = Id<Collection>;
impl From<CollectionId> for flix_model::id::CollectionId {
#[inline]
fn from(value: CollectionId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::CollectionId> for CollectionId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::CollectionId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Movie {}
/// Type alias for a movie ID
/// Type alias for a movie ID.
pub type MovieId = Id<Movie>;
impl From<MovieId> for flix_model::id::MovieId {
#[inline]
fn from(value: MovieId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::MovieId> for MovieId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::MovieId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum Show {}
/// Type alias for a show ID
/// Type alias for a show ID.
pub type ShowId = Id<Show>;
impl From<ShowId> for flix_model::id::ShowId {
#[inline]
fn from(value: ShowId) -> Self {
Self::from_raw(value.into_raw().into())
}
}
impl TryFrom<flix_model::id::ShowId> for ShowId {
type Error = <RawId as TryFrom<flix_model::id::RawId>>::Error;
type Error = <TmdbRepr as TryFrom<flix_model::id::RawId>>::Error;
#[inline]
fn try_from(value: flix_model::id::ShowId) -> Result<Self, Self::Error> {
value.into_raw().try_into().map(Self::from_raw)
}
+13 -9
View File
@@ -1,9 +1,9 @@
//! Deserializable types from the TMDB API
//! Deserializable types from the TMDB API.
use core::str::FromStr;
use core::str::FromStr as _;
use core::time::Duration;
use serde::{Deserialize, Deserializer};
use serde::{Deserialize as _, Deserializer};
use url::Url;
pub mod id;
@@ -20,14 +20,22 @@ pub use movie::*;
pub use season::*;
pub use show::*;
/// Deserializer for converting integer minutes to a [Duration].
///
/// # Errors
/// Fails if the value being deserialized isn't a [u64].
fn duration_from_minutes<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let minutes = u64::deserialize(deserializer).unwrap_or(0);
let minutes = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(minutes.saturating_mul(60)))
}
/// Deserializer for converting a string to a [Url].
///
/// # Errors
/// Fails if the value being deserialized isn't a [`&str`].
fn still_url_from_path<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
where
D: Deserializer<'de>,
@@ -38,10 +46,6 @@ where
let path = Option::<&str>::deserialize(deserializer)?;
Ok(path.and_then(|path| {
Url::from_str(&format!(
"{}{}{}",
TMDB_IMAGE_BASE, TMDB_IMAGE_QUALITY, path
))
.ok()
Url::from_str(&format!("{TMDB_IMAGE_BASE}{TMDB_IMAGE_QUALITY}{path}")).ok()
}))
}
+40 -11
View File
@@ -1,3 +1,5 @@
//! TMDB movie model.
use core::time::Duration;
use chrono::NaiveDate;
@@ -5,33 +7,35 @@ use chrono::NaiveDate;
use super::duration_from_minutes;
use super::id::{CollectionId, MovieId};
/// A deserialized Movie from the TMDB API
/// A deserialized Movie from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Movie {
/// The movie's TMDB ID
/// The movie's TMDB ID.
pub id: MovieId,
/// The movie's collection, if it exists
/// The movie's collection, if it exists.
#[serde(rename = "belongs_to_collection")]
pub collection: Option<InCollection>,
/// The movie's title
/// The movie's title.
pub title: String,
/// The movie's tagline
/// The movie's tagline.
pub tagline: String,
/// The movie's overview
/// The movie's overview.
pub overview: String,
/// The movie's release date
/// The movie's release date.
pub release_date: NaiveDate,
/// The movie's runtime
/// The movie's runtime.
#[serde(deserialize_with = "duration_from_minutes")]
pub runtime: Duration,
}
/// A deserialized movie's collection from the TMDB API
/// A deserialized movie's collection from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct InCollection {
/// The collection's TMDB ID
/// The collection's TMDB ID.
pub id: CollectionId,
/// The collection's title
/// The collection's title.
#[serde(rename = "name")]
pub title: String,
}
@@ -43,3 +47,28 @@ pub struct InCollection {
// TODO: Company
// pub companies: Vec<Company>
// where: struct Company { id, name }
#[cfg(test)]
mod tests {
use core::time::Duration;
use chrono::NaiveDate;
use super::{CollectionId, InCollection, Movie, MovieId};
#[test]
fn use_types() {
drop(Movie {
id: MovieId::from_raw(0),
collection: Some(InCollection {
id: CollectionId::from_raw(0),
title: String::new(),
}),
title: String::new(),
tagline: String::new(),
overview: String::new(),
release_date: NaiveDate::default(),
runtime: Duration::default(),
});
}
}
+31 -8
View File
@@ -1,23 +1,46 @@
//! TMDB season model.
use chrono::NaiveDate;
use flix_model::numbers::SeasonNumber;
/// A deserialized Season from the TMDB API
/// A deserialized Season from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Season {
/// The season's number
/// The season's number.
pub season_number: SeasonNumber,
/// The season's title
/// The season's title.
#[serde(rename = "name")]
pub title: String,
/// The season's overview
/// The season's overview.
pub overview: String,
/// The season's air date
/// The season's air date.
pub air_date: NaiveDate,
/// The number of episodes in this season
/// The number of episodes in this season.
pub episodes: Vec<FakeEpisode>,
}
/// A placeholder struct for parsing the episodes list for a season
#[derive(Debug, Clone, serde::Deserialize)]
/// A placeholder struct for parsing the episodes list for a season.
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[non_exhaustive]
#[expect(clippy::empty_structs_with_brackets, reason = "might add fields later")]
pub struct FakeEpisode {}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::{Season, SeasonNumber};
#[test]
fn use_types() {
drop(Season {
season_number: SeasonNumber::new(0),
title: String::new(),
overview: String::new(),
air_date: NaiveDate::default(),
episodes: Vec::new(),
});
}
}
+33 -9
View File
@@ -1,26 +1,29 @@
//! TMDB show model.
use chrono::NaiveDate;
use super::id::ShowId;
/// A deserialized Show from the TMDB API
/// A deserialized Show from the TMDB API.
#[derive(Debug, Clone, serde::Deserialize)]
#[non_exhaustive]
pub struct Show {
/// The show's TMDB ID
/// The show's TMDB ID.
pub id: ShowId,
/// The show's title
/// The show's title.
#[serde(rename = "name")]
pub title: String,
/// The show's tagline
/// The show's tagline.
pub tagline: String,
/// The show's overview
/// The show's overview.
pub overview: String,
/// The show's first air date
/// The show's first air date.
pub first_air_date: NaiveDate,
/// The show's last air date
/// The show's last air date.
pub last_air_date: NaiveDate,
/// The total number of episodes in this show
/// The total number of episodes in this show.
pub number_of_episodes: u32,
/// The number of seasons in this show
/// The number of seasons in this show.
pub number_of_seasons: u32,
}
@@ -35,3 +38,24 @@ pub struct Show {
// TODO: Company
// pub companies: Vec<Company>
// where: struct Company { id, name }
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::{Show, ShowId};
#[test]
fn use_types() {
drop(Show {
id: ShowId::from_raw(0),
title: String::new(),
tagline: String::new(),
overview: String::new(),
first_air_date: NaiveDate::default(),
last_air_date: NaiveDate::default(),
number_of_episodes: 0,
number_of_seasons: 0,
});
}
}