TMDB API integration
This commit is contained in:
parent
272d06897a
commit
b049b75873
2 changed files with 280 additions and 0 deletions
2
src/tmdb/mod.rs
Normal file
2
src/tmdb/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
mod service;
|
||||
pub use service::*;
|
278
src/tmdb/service.rs
Normal file
278
src/tmdb/service.rs
Normal file
|
@ -0,0 +1,278 @@
|
|||
use crate::activitypub::actors::helpers::ACTOR_IMAGE_MAX_SIZE;
|
||||
use crate::activitypub::fetcher::fetchers::fetch_file;
|
||||
use crate::mastodon_api::oauth::utils::generate_access_token;
|
||||
use crate::validators::users::validate_local_username;
|
||||
use mitra_config::Instance;
|
||||
use mitra_models::database::DatabaseClient;
|
||||
use mitra_models::profiles::queries::update_profile;
|
||||
use mitra_models::profiles::types::{ExtraField, ProfileImage, ProfileUpdateData};
|
||||
use mitra_models::users::queries::create_user;
|
||||
use mitra_models::users::types::{Role, User, UserCreateData};
|
||||
use mitra_utils::crypto_rsa::{generate_rsa_key, serialize_private_key};
|
||||
use mitra_utils::markdown::markdown_basic_to_html;
|
||||
use mitra_utils::passwords::hash_password;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn lookup_and_create_movie_user(
|
||||
instance: &Instance,
|
||||
db_client: &mut impl DatabaseClient,
|
||||
api_key: &str,
|
||||
media_dir: &Path,
|
||||
username: &str,
|
||||
default_movie_user_password: Option<String>,
|
||||
) -> Result<User, MovieError> {
|
||||
let (movie_title, year) = username.rsplit_once('_').ok_or(MovieError::NotMovie)?;
|
||||
let year = year.parse::<u32>().map_err(|_| MovieError::NotMovie)?;
|
||||
|
||||
let movie_info = get_movie_info(api_key, movie_title, year).await?;
|
||||
let user = create_movie_user(
|
||||
instance,
|
||||
db_client,
|
||||
&movie_info,
|
||||
&default_movie_user_password
|
||||
.clone()
|
||||
.unwrap_or_else(generate_access_token),
|
||||
media_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum MovieError {
|
||||
#[error("error calling TMDB API: {0}")]
|
||||
NotFoundError(&'static str),
|
||||
|
||||
#[error("not a movie")]
|
||||
NotMovie,
|
||||
|
||||
#[error("error creating movie user: {0}")]
|
||||
UserCreationError(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub async fn get_movie_info(
|
||||
api_key: &str,
|
||||
movie_username: &str,
|
||||
year: u32,
|
||||
) -> Result<MovieInfo, MovieError> {
|
||||
// Expand movie title to reverse the camel case on spaces
|
||||
let movie_title = movie_username
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_uppercase() {
|
||||
format!(" {}", c)
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"https://api.themoviedb.org/3/search/movie?api_key={api_key}&query={movie_title}&year={year}&include_adult=false&language=en-US",
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "FediMovies.rocks/1.0")
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| MovieError::NotFoundError("sending request"))?;
|
||||
let response = response
|
||||
.json::<MovieSearchResponse>()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
log::error!("error parsing result from TMDB API ({url}): {err:?}");
|
||||
MovieError::NotFoundError("error parsing result from TMDB API")
|
||||
})?;
|
||||
let movie_info = response.results.first().ok_or_else(|| {
|
||||
log::error!("movie not found in TMDB API ({url})");
|
||||
MovieError::NotFoundError("movie not found in TMDB API")
|
||||
})?;
|
||||
|
||||
if movie_info.movie_username().starts_with(movie_username)
|
||||
&& movie_info.release_date.starts_with(&year.to_string())
|
||||
&& !movie_info.adult
|
||||
{
|
||||
Ok(movie_info.clone())
|
||||
} else {
|
||||
let movie_username_gen = movie_info.movie_username();
|
||||
log::error!("does not movie match any movie found in TMDB API ({url}) - {movie_username_gen} != {movie_username}");
|
||||
Err(MovieError::NotFoundError(
|
||||
"does not movie match any movie found in TMDB API",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MovieSearchResponse {
|
||||
pub page: u32,
|
||||
pub results: Vec<MovieInfo>,
|
||||
pub total_results: u32,
|
||||
pub total_pages: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct MovieInfo {
|
||||
pub poster_path: Option<String>,
|
||||
pub adult: bool,
|
||||
pub overview: String,
|
||||
pub release_date: String,
|
||||
pub id: u32,
|
||||
pub original_title: String,
|
||||
pub original_language: String,
|
||||
pub title: String,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub popularity: f32,
|
||||
pub vote_count: u32,
|
||||
pub video: bool,
|
||||
pub vote_average: f32,
|
||||
}
|
||||
|
||||
impl MovieInfo {
|
||||
pub fn movie_username(&self) -> String {
|
||||
// Sanitize the movie title by removing all non-alphanumeric characters and replacing
|
||||
// space with camel case letter.
|
||||
let title = self
|
||||
.title
|
||||
.replace(|c: char| !c.is_alphanumeric() && !c.is_whitespace(), "");
|
||||
let title = title
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first_char) => first_char.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
format!("{}_{}", title, self.release_date.split('-').next().unwrap())
|
||||
}
|
||||
|
||||
pub fn poster_url(&self) -> Option<String> {
|
||||
self.poster_path
|
||||
.as_ref()
|
||||
.map(|path| format!("https://image.tmdb.org/t/p/w185{}", path))
|
||||
}
|
||||
|
||||
pub fn background_url(&self) -> Option<String> {
|
||||
self.backdrop_path
|
||||
.as_ref()
|
||||
.map(|path| format!("https://image.tmdb.org/t/p/w780{}", path))
|
||||
}
|
||||
|
||||
pub fn movie_url(&self) -> String {
|
||||
format!("https://www.themoviedb.org/movie/{}", self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_movie_user(
|
||||
instance: &Instance,
|
||||
db_client: &mut impl DatabaseClient,
|
||||
movie_info: &MovieInfo,
|
||||
password: &str,
|
||||
media_dir: &Path,
|
||||
) -> Result<User, anyhow::Error> {
|
||||
let username = movie_info.movie_username();
|
||||
validate_local_username(&username)?;
|
||||
let password_hash = hash_password(password)?;
|
||||
let private_key = generate_rsa_key()?;
|
||||
let private_key_pem = serialize_private_key(&private_key)?;
|
||||
let user_data = UserCreateData {
|
||||
username: username.clone(),
|
||||
password_hash: Some(password_hash),
|
||||
private_key_pem,
|
||||
wallet_address: None,
|
||||
invite_code: None,
|
||||
role: Role::NormalUser,
|
||||
};
|
||||
let mut user = create_user(db_client, user_data).await?;
|
||||
log::info!("user {username} created");
|
||||
// Update profile
|
||||
let mut profile_data = ProfileUpdateData::from(&user.profile);
|
||||
|
||||
let tmdb_profile_url = movie_info.movie_url();
|
||||
profile_data.extra_fields = vec![ExtraField {
|
||||
name: "TMDB Profile".to_string(),
|
||||
value: markdown_basic_to_html(&tmdb_profile_url)
|
||||
.unwrap_or_else(|_| tmdb_profile_url.clone()),
|
||||
value_source: Some(tmdb_profile_url),
|
||||
}];
|
||||
profile_data.bio = Some(movie_info.overview.clone());
|
||||
profile_data.display_name = Some(movie_info.title.clone());
|
||||
match movie_info.poster_url() {
|
||||
Some(poster_url) => {
|
||||
profile_data.avatar =
|
||||
match fetch_file(instance, &poster_url, None, ACTOR_IMAGE_MAX_SIZE, media_dir).await
|
||||
{
|
||||
Ok((file_name, file_size, maybe_media_type)) => {
|
||||
let image = ProfileImage::new(file_name, file_size, maybe_media_type);
|
||||
Some(image)
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("failed to fetch movie poster ({})", error);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => profile_data.avatar = None,
|
||||
}
|
||||
match movie_info.background_url() {
|
||||
Some(background_url) => {
|
||||
profile_data.banner = match fetch_file(
|
||||
instance,
|
||||
&background_url,
|
||||
None,
|
||||
ACTOR_IMAGE_MAX_SIZE,
|
||||
media_dir,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((file_name, file_size, maybe_media_type)) => {
|
||||
let image = ProfileImage::new(file_name, file_size, maybe_media_type);
|
||||
Some(image)
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("failed to fetch movie background ({})", error);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => profile_data.banner = None,
|
||||
}
|
||||
user.profile = update_profile(db_client, &user.id, profile_data).await?;
|
||||
log::info!("user {username} profile updated");
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_movie_username() {
|
||||
let movie = MovieInfo {
|
||||
poster_path: None,
|
||||
adult: false,
|
||||
overview: String::new(),
|
||||
release_date: String::from("2022-01-01"),
|
||||
id: 0,
|
||||
original_title: String::new(),
|
||||
original_language: String::new(),
|
||||
title: String::from("Avatar: The Way of Water"),
|
||||
backdrop_path: None,
|
||||
popularity: 0.0,
|
||||
vote_count: 0,
|
||||
video: false,
|
||||
vote_average: 0.0,
|
||||
};
|
||||
|
||||
assert_eq!(movie.movie_username(), "AvatarTheWayOfWater_2022");
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue