M1: MovieRepository→MovieCommand/MovieQuery, WatchEventRepository→ WatchEventCommand/WatchEventQuery M2: goals/ and import/ use Deps structs M7: extract upload_image helper in update_profile M8: FederationDeliveryRequested activity_json String→serde_json::Value M11: UserProfileResponse uses ProfileViewData enum
186 lines
5.4 KiB
Rust
186 lines
5.4 KiB
Rust
use async_trait::async_trait;
|
|
use domain::{
|
|
errors::DomainError,
|
|
models::{MetadataSearchCriteria, Movie},
|
|
ports::{MetadataClient, MovieQuery},
|
|
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
|
};
|
|
|
|
use crate::diary::commands::MovieInput;
|
|
|
|
pub struct MovieResolverDeps<'a> {
|
|
pub repository: &'a dyn MovieQuery,
|
|
pub metadata_client: &'a dyn MetadataClient,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait ResolutionStrategy: Send + Sync {
|
|
fn can_handle(&self, input: &MovieInput) -> bool;
|
|
async fn resolve(
|
|
&self,
|
|
input: &MovieInput,
|
|
deps: &MovieResolverDeps<'_>,
|
|
) -> Result<Option<(Movie, bool)>, DomainError>;
|
|
}
|
|
|
|
pub struct ExternalIdStrategy;
|
|
pub struct TitleSearchStrategy;
|
|
pub struct ManualMovieStrategy;
|
|
|
|
pub struct MovieResolver {
|
|
strategies: Vec<Box<dyn ResolutionStrategy>>,
|
|
}
|
|
|
|
impl MovieResolver {
|
|
pub fn default_pipeline() -> Self {
|
|
Self {
|
|
strategies: vec![
|
|
Box::new(ExternalIdStrategy),
|
|
Box::new(TitleSearchStrategy),
|
|
Box::new(ManualMovieStrategy),
|
|
],
|
|
}
|
|
}
|
|
|
|
pub async fn resolve(
|
|
&self,
|
|
input: &MovieInput,
|
|
deps: &MovieResolverDeps<'_>,
|
|
) -> Result<(Movie, bool), DomainError> {
|
|
for strategy in &self.strategies {
|
|
if strategy.can_handle(input)
|
|
&& let Some(result) = strategy.resolve(input, deps).await?
|
|
{
|
|
return Ok(result);
|
|
}
|
|
}
|
|
Err(DomainError::ValidationError(
|
|
"Manual title required if TMDB fetch fails or is omitted".into(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ResolutionStrategy for ExternalIdStrategy {
|
|
fn can_handle(&self, input: &MovieInput) -> bool {
|
|
input.external_metadata_id.is_some()
|
|
}
|
|
|
|
async fn resolve(
|
|
&self,
|
|
input: &MovieInput,
|
|
deps: &MovieResolverDeps<'_>,
|
|
) -> Result<Option<(Movie, bool)>, DomainError> {
|
|
let ext_id_str = input.external_metadata_id.as_deref().unwrap();
|
|
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
|
|
|
|
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
|
|
return Ok(Some((m, false)));
|
|
}
|
|
|
|
match deps
|
|
.metadata_client
|
|
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
|
|
.await
|
|
{
|
|
Ok(m) => Ok(Some((m, true))),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
|
|
e
|
|
);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ResolutionStrategy for TitleSearchStrategy {
|
|
fn can_handle(&self, input: &MovieInput) -> bool {
|
|
input.manual_title.is_some()
|
|
}
|
|
|
|
async fn resolve(
|
|
&self,
|
|
input: &MovieInput,
|
|
deps: &MovieResolverDeps<'_>,
|
|
) -> Result<Option<(Movie, bool)>, DomainError> {
|
|
let title = input.manual_title.as_deref().unwrap();
|
|
let criteria = MetadataSearchCriteria::Title {
|
|
title: MovieTitle::new(title.to_string())?,
|
|
year: input
|
|
.manual_release_year
|
|
.map(ReleaseYear::new)
|
|
.transpose()?,
|
|
};
|
|
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
|
|
Ok(m) => {
|
|
// Movie may already exist in DB under this external_metadata_id
|
|
if let Some(ext_id) = m.external_metadata_id()
|
|
&& let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
|
|
{
|
|
return Ok(Some((existing, false)));
|
|
}
|
|
Ok(Some((m, true)))
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ResolutionStrategy for ManualMovieStrategy {
|
|
fn can_handle(&self, input: &MovieInput) -> bool {
|
|
input.manual_title.is_some()
|
|
}
|
|
|
|
async fn resolve(
|
|
&self,
|
|
input: &MovieInput,
|
|
deps: &MovieResolverDeps<'_>,
|
|
) -> Result<Option<(Movie, bool)>, DomainError> {
|
|
let title_str = match &input.manual_title {
|
|
Some(t) => t,
|
|
None => return Ok(None),
|
|
};
|
|
let year_val = input.manual_release_year.ok_or_else(|| {
|
|
DomainError::ValidationError(
|
|
"Manual release year required if TMDB fetch fails or is omitted".into(),
|
|
)
|
|
})?;
|
|
|
|
let title = MovieTitle::new(title_str.clone())?;
|
|
let release_year = ReleaseYear::new(year_val)?;
|
|
|
|
let candidates = deps
|
|
.repository
|
|
.get_movies_by_title_and_year(&title, &release_year)
|
|
.await?;
|
|
|
|
let matched = candidates
|
|
.into_iter()
|
|
.find(|m| m.is_manual_match(&title, &release_year, input.manual_director.as_deref()));
|
|
|
|
if let Some(existing) = matched {
|
|
Ok(Some((existing, false)))
|
|
} else {
|
|
let new_movie = Movie::new(
|
|
None,
|
|
title,
|
|
release_year,
|
|
input.manual_director.clone(),
|
|
None,
|
|
);
|
|
Ok(Some((new_movie, true)))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests/movie_resolver.rs"]
|
|
mod tests;
|