feat: search reindex, worker improvements, person IDs, user display names
- add admin POST /api/v1/admin/reindex-search endpoint + event-driven handler - backfill persons from movie_cast/movie_crew into persons table - paginate person list_page/backfill_from_credits_batch to cap memory - concurrent worker event dispatch with semaphore (max 8) - graceful worker shutdown (drain in-flight tasks on SIGINT) - always ack events, log handler errors as warnings (no infinite retry) - NATS ack_wait 600s, AtomicBool guard against concurrent reindex - add username/display_name to UserSummaryDto and users list - add person_id to CastMemberDto/CrewMemberDto via get_movie_profile use case - add movie_id to wrapup MovieRef, person_id to wrapup PersonStat - thread tmdb_person_id through wrapup cast pipeline - add is_federated to FeedEntryDto - cap orphaned persons query with LIMIT 500 - add SPA link to classic site footer
This commit is contained in:
@@ -332,61 +332,67 @@ 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.repos.movie_profile.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(),
|
||||
use application::movies::get_movie_profile;
|
||||
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
||||
match get_movie_profile::execute(&state.app_ctx, query).await {
|
||||
Ok(Some(result)) => {
|
||||
let p = result.profile;
|
||||
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: result
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|c| CastMemberDto {
|
||||
person_id: c.person_id.value().to_string(),
|
||||
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: result
|
||||
.crew
|
||||
.into_iter()
|
||||
.map(|c| CrewMemberDto {
|
||||
person_id: c.person_id.value().to_string(),
|
||||
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) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
@@ -982,6 +988,20 @@ pub async fn get_pending_followers(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_reindex_search(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminApiUser,
|
||||
) -> impl IntoResponse {
|
||||
let event = domain::events::DomainEvent::SearchReindexRequested;
|
||||
match state.app_ctx.services.event_publisher.publish(&event).await {
|
||||
Ok(()) => StatusCode::ACCEPTED,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to publish reindex event: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/activity-feed",
|
||||
params(ActivityFeedQueryParams),
|
||||
@@ -1032,6 +1052,8 @@ pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersRespo
|
||||
.map(|u| UserSummaryDto {
|
||||
id: u.user_id.value(),
|
||||
email: u.email().to_string(),
|
||||
username: u.username().to_string(),
|
||||
display_name: u.display_name().map(String::from),
|
||||
total_movies: u.total_movies,
|
||||
avg_rating: u.avg_rating,
|
||||
})
|
||||
|
||||
@@ -10,5 +10,6 @@ pub fn feed_entry_to_dto(e: &FeedEntry) -> FeedEntryDto {
|
||||
user_id: e.review().user_id().value(),
|
||||
user_email: e.user_email().to_string(),
|
||||
user_display_name: e.user_display_name().to_string(),
|
||||
is_federated: e.review().is_remote(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use domain::ports::RemoteActorInfo;
|
||||
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
|
||||
|
||||
pub fn user_summary_view(u: &UserSummary) -> UserSummaryView {
|
||||
let name = u.email().split('@').next().unwrap_or("?").to_string();
|
||||
let name = u
|
||||
.display_name()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| u.username().to_string());
|
||||
let initial = name.chars().next().unwrap_or('?').to_ascii_uppercase();
|
||||
let avg_display = u
|
||||
.avg_rating
|
||||
|
||||
@@ -432,6 +432,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route(
|
||||
"/wrapups/{id}/video",
|
||||
routing::get(handlers::wrapup::get_video),
|
||||
)
|
||||
.route(
|
||||
"/admin/reindex-search",
|
||||
routing::post(handlers::api::post_reindex_search),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
|
||||
@@ -60,6 +60,13 @@ impl domain::ports::PersonQuery for PersonQueryStub {
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_page(
|
||||
&self,
|
||||
_limit: u32,
|
||||
_offset: u32,
|
||||
) -> Result<Vec<domain::models::Person>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search endpoint tests ---
|
||||
|
||||
@@ -431,6 +431,12 @@ impl PersonCommand for Panic {
|
||||
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
_batch_size: u32,
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PersonQuery for Panic {
|
||||
@@ -449,6 +455,13 @@ impl PersonQuery for Panic {
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_page(
|
||||
&self,
|
||||
_limit: u32,
|
||||
_offset: u32,
|
||||
) -> Result<Vec<domain::models::Person>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl SearchPort for Panic {
|
||||
|
||||
Reference in New Issue
Block a user