feat: extensible search engine with person entities (FTS5/tsvector)

This commit is contained in:
2026-05-12 18:45:24 +02:00
parent 763d622601
commit c6770659c5
45 changed files with 2421 additions and 86 deletions

View File

@@ -21,11 +21,12 @@ use application::{
get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile,
search as search_uc, get_person, get_person_credits,
},
};
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, Review},
models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams},
services::review_history::Trend,
value_objects::{MovieId, UserId},
};
@@ -44,6 +45,10 @@ use api_types::{
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
PersonSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, SearchQueryParams, SearchResponse,
};
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
@@ -1088,3 +1093,117 @@ pub async fn export_diary(
}
}
}
// Search and person endpoints are intentionally public — browsing the catalog
// and people profiles does not require authentication.
pub async fn get_search(
State(state): State<AppState>,
Query(params): Query<SearchQueryParams>,
) -> impl IntoResponse {
let query = domain::models::SearchQuery {
text: params.q,
filters: domain::models::SearchFilters {
genre: params.genre,
year: params.year,
person_id: params.person_id.map(PersonId::from_uuid),
department: params.department,
language: params.language,
},
page: PageParams {
limit: params.limit.unwrap_or(5),
offset: params.offset.unwrap_or(0),
},
};
match search_uc::execute(&state.app_ctx, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results.movies.items.iter().map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
}).collect(),
total_count: results.movies.total_count,
limit: results.movies.limit,
offset: results.movies.offset,
},
people: PaginatedPersonHits {
items: results.people.items.iter().map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
}).collect(),
total_count: results.people.total_count,
limit: results.people.limit,
offset: results.people.offset,
},
}).into_response(),
Err(e) => {
tracing::error!("search failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn get_person_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
match get_person::execute(&state.app_ctx, PersonId::from_uuid(id)).await {
Ok(Some(person)) => axum::Json(PersonDto {
id: person.id().value(),
external_id: person.external_id().value().to_string(),
name: person.name().to_string(),
known_for_department: person.known_for_department().map(str::to_string),
profile_path: person.profile_path().map(str::to_string),
}).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn get_person_credits_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
match get_person_credits::execute(&state.app_ctx, PersonId::from_uuid(id)).await {
Ok(credits) => axum::Json(PersonCreditsDto {
person: PersonDto {
id: credits.person.id().value(),
external_id: credits.person.external_id().value().to_string(),
name: credits.person.name().to_string(),
known_for_department: credits.person.known_for_department().map(str::to_string),
profile_path: credits.person.profile_path().map(str::to_string),
},
cast: credits.cast.iter().map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
crew: credits.crew.iter().map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
}).into_response(),
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person_credits failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}

View File

@@ -7,3 +7,6 @@ pub mod openapi;
pub mod ports;
pub mod routes;
pub mod state;
#[cfg(test)]
mod tests;

View File

@@ -15,6 +15,11 @@ use presentation::{openapi, routes, state::AppState};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
#[cfg(feature = "sqlite")]
use sqlite_search;
#[cfg(feature = "postgres")]
use postgres_search;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -49,17 +54,21 @@ 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, movie_profile_repository, db_pool) =
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, person_command, person_query, search_command, search_port, db_pool) =
match backend.as_str() {
#[cfg(feature = "postgres")]
"postgres" => {
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))
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Postgres(pool))
}
#[cfg(feature = "sqlite")]
_ => {
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))
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Sqlite(pool))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
@@ -121,14 +130,14 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let event_publisher_arc: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match backend.as_str() {
match &db_pool {
#[cfg(feature = "postgres")]
"postgres" => postgres_event_queue::PostgresEventQueue::create_publisher(
pg_pool.as_ref().unwrap().clone()
DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_publisher(
pool.clone()
).await?,
#[cfg(feature = "sqlite")]
_ => sqlite_event_queue::SqliteEventQueue::create_publisher(
sqlite_pool.as_ref().unwrap().clone()
DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_publisher(
pool.clone()
).await?,
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"),
@@ -162,6 +171,10 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
movie_profile_repository,
person_command,
person_query,
search_port,
search_command,
config: app_config,
};

View File

@@ -210,7 +210,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler));
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
.route("/search", routing::get(handlers::api::get_search))
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler));
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());

View File

@@ -0,0 +1,142 @@
use super::extractors::{make_test_state, Panic};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// Custom stub for SearchPort that returns empty results instead of panicking
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(&self, _: &domain::models::SearchQuery) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
})
}
}
// Custom stub for PersonQuery that returns 404 instead of panicking
struct PersonQueryStub;
#[async_trait::async_trait]
impl domain::ports::PersonQuery for PersonQueryStub {
async fn get_by_id(&self, _: &domain::models::PersonId) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None) // Return None to trigger 404
}
async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None)
}
async fn get_credits(&self, _: &domain::models::PersonId) -> Result<domain::models::PersonCredits, DomainError> {
Err(DomainError::NotFound("Person not found".into()))
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub);
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=test&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub);
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=&limit=5&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// --- Person endpoint tests ---
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}", get(crate::handlers::api::get_person_handler))
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(&format!("/api/v1/people/{}", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}/credits", get(crate::handlers::api::get_person_credits_handler))
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(&format!("/api/v1/people/{}/credits", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

View File

@@ -13,11 +13,14 @@ use domain::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
@@ -29,7 +32,7 @@ use tower::ServiceExt;
// --- Panic stubs (defined once) ---
struct Panic;
pub struct Panic;
#[async_trait::async_trait]
impl MovieRepository for Panic {
@@ -350,9 +353,29 @@ impl AuthService for RejectingAuth {
}
}
#[async_trait::async_trait]
impl PersonCommand for Panic {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() }
}
#[async_trait::async_trait]
impl PersonQuery for Panic {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl SearchPort for Panic {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl SearchCommand for Panic {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
}
// --- Single state factory — only auth_service varies ---
fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
let repo = Arc::new(Panic);
crate::state::AppState {
app_ctx: AppContext {
@@ -371,6 +394,10 @@ fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState
import_session_repository: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,
search_command: Arc::clone(&repo) as _,
auth_service,
config: AppConfig {
allow_registration: false,

View File

@@ -0,0 +1,45 @@
// Re-export imports needed by subtest modules
pub use application::{config::AppConfig, context::AppContext};
pub use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
pub use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
pub use std::sync::Arc;
pub use tower::ServiceExt;
// API types for tests
pub use api_types::{
LoginRequest, LogReviewRequest, DiaryQueryParams,
};
pub use crate::{
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
forms::{LogReviewData, LogReviewForm, to_diary_query},
state::AppState,
};
mod extractors;
mod forms;
mod api_handlers;