feat: implement TMDb enrichment for movie profiles
- Add SqliteMovieProfileRepository for managing movie profiles in SQLite. - Create TmdbEnrichmentClient to fetch movie details from TMDb API. - Implement enrichment event handling with EnrichmentHandler. - Introduce periodic jobs for cleaning up expired import sessions and checking for stale movie profiles. - Update application context to include movie profile repository. - Add API endpoint to retrieve movie profiles. - Extend domain models with new structures for movie enrichment (Genre, Keyword, CastMember, CrewMember, MovieProfile). - Modify event system to include MovieEnrichmentRequested event. - Enhance tests to cover new functionality and ensure stability.
This commit is contained in:
@@ -358,6 +358,12 @@ mod tests {
|
||||
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MovieProfileRepository for Panic {
|
||||
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
|
||||
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
|
||||
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::DiaryExporter for Panic {
|
||||
async fn serialize_entries(
|
||||
&self,
|
||||
@@ -483,6 +489,7 @@ mod tests {
|
||||
user_repository: Arc::clone(&repo) as _,
|
||||
import_session_repository: Arc::clone(&repo) as _,
|
||||
import_profile_repository: Arc::clone(&repo) as _,
|
||||
movie_profile_repository: Arc::clone(&repo) as _,
|
||||
auth_service,
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
|
||||
@@ -36,9 +36,10 @@ use api_types::{
|
||||
BlockedDomainResponse, FollowRequest, RemoteActorDto,
|
||||
};
|
||||
use api_types::{
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse,
|
||||
DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewRequest, LoginRequest, LoginResponse,
|
||||
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, CastMemberDto, CrewMemberDto, DiaryEntryDto,
|
||||
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
|
||||
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
|
||||
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
|
||||
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse,
|
||||
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
|
||||
UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
@@ -293,6 +294,52 @@ pub async fn get_movie_detail(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies/{id}/profile",
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieProfileResponse),
|
||||
(status = 404, description = "No profile found for this movie"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_profile(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let id = domain::value_objects::MovieId::from_uuid(movie_id);
|
||||
match state.app_ctx.movie_profile_repository.get_by_movie_id(&id).await {
|
||||
Ok(Some(p)) => Json(MovieProfileResponse {
|
||||
tmdb_id: p.tmdb_id,
|
||||
imdb_id: p.imdb_id,
|
||||
overview: p.overview,
|
||||
tagline: p.tagline,
|
||||
runtime_minutes: p.runtime_minutes,
|
||||
budget_usd: p.budget_usd,
|
||||
revenue_usd: p.revenue_usd,
|
||||
vote_average: p.vote_average,
|
||||
vote_count: p.vote_count,
|
||||
original_language: p.original_language,
|
||||
collection_name: p.collection_name,
|
||||
genres: p.genres.into_iter().map(|g| GenreDto { tmdb_id: g.tmdb_id, name: g.name }).collect(),
|
||||
keywords: p.keywords.into_iter().map(|k| KeywordDto { tmdb_id: k.tmdb_id, name: k.name }).collect(),
|
||||
cast: p.cast.into_iter().map(|c| CastMemberDto {
|
||||
tmdb_person_id: c.tmdb_person_id, name: c.name, character: c.character,
|
||||
billing_order: c.billing_order, profile_path: c.profile_path,
|
||||
}).collect(),
|
||||
crew: p.crew.into_iter().map(|c| CrewMemberDto {
|
||||
tmdb_person_id: c.tmdb_person_id, name: c.name, job: c.job,
|
||||
department: c.department, profile_path: c.profile_path,
|
||||
}).collect(),
|
||||
enriched_at: p.enriched_at.to_rfc3339(),
|
||||
}).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("get_movie_profile: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/profile",
|
||||
responses(
|
||||
|
||||
@@ -49,17 +49,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let image_storage = image_storage::create()?;
|
||||
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, db_pool) =
|
||||
match backend.as_str() {
|
||||
#[cfg(feature = "postgres")]
|
||||
"postgres" => {
|
||||
let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, DbPool::Postgres(pool))
|
||||
let (pool, m, r, d, s, u, is, ip, mp) = postgres::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, mp, DbPool::Postgres(pool))
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
_ => {
|
||||
let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, DbPool::Sqlite(pool))
|
||||
let (pool, m, r, d, s, u, is, ip, mp) = sqlite::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, mp, DbPool::Sqlite(pool))
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
|
||||
@@ -161,6 +161,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
user_repository,
|
||||
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
|
||||
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
||||
movie_profile_repository,
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
@@ -185,6 +186,7 @@ enum DbPool {
|
||||
Postgres(sqlx::PgPool),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum EventBusBackend {
|
||||
Db,
|
||||
|
||||
@@ -181,6 +181,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/movies/{id}",
|
||||
routing::get(handlers::api::get_movie_detail),
|
||||
)
|
||||
.route(
|
||||
"/movies/{id}/profile",
|
||||
routing::get(handlers::api::get_movie_profile),
|
||||
)
|
||||
.route("/reviews", routing::post(handlers::api::post_review))
|
||||
.route(
|
||||
"/reviews/{id}",
|
||||
|
||||
@@ -147,6 +147,14 @@ impl domain::ports::DocumentParser for PanicDocumentParser {
|
||||
}
|
||||
|
||||
struct PanicImportProfile;
|
||||
|
||||
struct PanicMovieProfile;
|
||||
#[async_trait]
|
||||
impl domain::ports::MovieProfileRepository for PanicMovieProfile {
|
||||
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
|
||||
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
|
||||
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
|
||||
}
|
||||
#[async_trait]
|
||||
impl domain::ports::ImportProfileRepository for PanicImportProfile {
|
||||
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() }
|
||||
@@ -198,6 +206,7 @@ async fn test_app() -> Router {
|
||||
user_repository: Arc::new(NobodyUserRepo),
|
||||
import_session_repository: Arc::new(PanicImportSession),
|
||||
import_profile_repository: Arc::new(PanicImportProfile),
|
||||
movie_profile_repository: Arc::new(PanicMovieProfile),
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user