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

26
Cargo.lock generated
View File

@@ -3746,6 +3746,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "postgres-search"
version = "0.1.0"
dependencies = [
"async-trait",
"domain",
"sqlx",
"uuid",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3797,12 +3807,14 @@ dependencies = [
"postgres",
"postgres-event-queue",
"postgres-federation",
"postgres-search",
"rss 0.1.0",
"serde",
"serde_json",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlite-search",
"sqlx",
"template-askama",
"tokio",
@@ -5002,6 +5014,17 @@ dependencies = [
"uuid",
]
[[package]]
name = "sqlite-search"
version = "0.1.0"
dependencies = [
"async-trait",
"domain",
"sqlx",
"tokio",
"uuid",
]
[[package]]
name = "sqlx"
version = "0.8.6"
@@ -5543,6 +5566,7 @@ name = "tmdb-enrichment"
version = "0.1.0"
dependencies = [
"anyhow",
"application",
"async-trait",
"chrono",
"domain",
@@ -6798,9 +6822,11 @@ dependencies = [
"postgres",
"postgres-event-queue",
"postgres-federation",
"postgres-search",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlite-search",
"sqlx",
"tmdb-enrichment",
"tokio",

View File

@@ -28,6 +28,8 @@ members = [
"crates/tui",
"crates/worker",
"crates/adapters/importer",
"crates/adapters/sqlite-search",
"crates/adapters/postgres-search",
]
resolver = "2"
@@ -81,3 +83,5 @@ sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
importer = { path = "crates/adapters/importer" }
image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" }

View File

@@ -15,26 +15,35 @@ pub struct NatsConfig {
impl NatsConfig {
pub fn from_env() -> anyhow::Result<Self> {
let url = std::env::var("NATS_URL")
.map_err(|_| anyhow::anyhow!("NATS_URL is not set"))?;
Self::from_vars(
std::env::var("NATS_URL").ok().as_deref(),
std::env::var("NATS_MODE").ok().as_deref(),
std::env::var("NATS_SUBJECT_PREFIX").ok().as_deref(),
std::env::var("NATS_STREAM_NAME").ok().as_deref(),
std::env::var("NATS_CONSUMER_NAME").ok().as_deref(),
)
}
let mode = match std::env::var("NATS_MODE")
.unwrap_or_else(|_| "jetstream".to_string())
.as_str()
{
pub(crate) fn from_vars(
url: Option<&str>,
mode: Option<&str>,
subject_prefix: Option<&str>,
stream_name: Option<&str>,
consumer_name: Option<&str>,
) -> anyhow::Result<Self> {
let url = url.ok_or_else(|| anyhow::anyhow!("NATS_URL is not set"))?;
let mode = match mode.unwrap_or("jetstream") {
"core" => NatsMode::Core,
"jetstream" => NatsMode::JetStream,
other => anyhow::bail!("unknown NATS_MODE: {other}"),
};
let subject_prefix = std::env::var("NATS_SUBJECT_PREFIX")
.unwrap_or_else(|_| "movies-diary.events".to_string());
let stream_name = std::env::var("NATS_STREAM_NAME")
.unwrap_or_else(|_| "MOVIES_DIARY_EVENTS".to_string());
let consumer_name = std::env::var("NATS_CONSUMER_NAME")
.unwrap_or_else(|_| "worker".to_string());
let subject_prefix = subject_prefix.unwrap_or("movies-diary.events").to_string();
let stream_name = stream_name.unwrap_or("MOVIES_DIARY_EVENTS").to_string();
let consumer_name = consumer_name.unwrap_or("worker").to_string();
Ok(Self { url, mode, subject_prefix, stream_name, consumer_name })
Ok(Self { url: url.to_string(), mode, subject_prefix, stream_name, consumer_name })
}
}

View File

@@ -2,57 +2,26 @@ use super::*;
#[test]
fn errors_without_nats_url() {
unsafe { std::env::remove_var("NATS_URL"); }
assert!(NatsConfig::from_env().is_err());
assert!(NatsConfig::from_vars(None, None, None, None, None).is_err());
}
#[test]
fn defaults_with_only_url() {
unsafe {
std::env::set_var("NATS_URL", "nats://localhost:4222");
std::env::remove_var("NATS_MODE");
std::env::remove_var("NATS_SUBJECT_PREFIX");
std::env::remove_var("NATS_STREAM_NAME");
std::env::remove_var("NATS_CONSUMER_NAME");
}
let cfg = NatsConfig::from_env().unwrap();
let cfg = NatsConfig::from_vars(Some("nats://localhost:4222"), None, None, None, None).unwrap();
assert_eq!(cfg.url, "nats://localhost:4222");
assert_eq!(cfg.mode, NatsMode::JetStream);
assert_eq!(cfg.subject_prefix, "movies-diary.events");
assert_eq!(cfg.stream_name, "MOVIES_DIARY_EVENTS");
assert_eq!(cfg.consumer_name, "worker");
unsafe { std::env::remove_var("NATS_URL"); }
}
#[test]
fn core_mode_parsed() {
unsafe {
std::env::set_var("NATS_URL", "nats://test:4222");
std::env::set_var("NATS_MODE", "core");
}
let cfg = NatsConfig::from_env().unwrap();
let cfg = NatsConfig::from_vars(Some("nats://test:4222"), Some("core"), None, None, None).unwrap();
assert_eq!(cfg.mode, NatsMode::Core);
unsafe {
std::env::remove_var("NATS_URL");
std::env::remove_var("NATS_MODE");
}
}
#[test]
fn invalid_mode_errors() {
unsafe {
std::env::set_var("NATS_URL", "nats://test:4222");
std::env::set_var("NATS_MODE", "kafka");
}
assert!(NatsConfig::from_env().is_err());
unsafe {
std::env::remove_var("NATS_URL");
std::env::remove_var("NATS_MODE");
}
assert!(NatsConfig::from_vars(Some("nats://test:4222"), Some("kafka"), None, None, None).is_err());
}

View File

@@ -0,0 +1,10 @@
[package]
name = "postgres-search"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
uuid = { workspace = true }

View File

@@ -0,0 +1,313 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
collections::Paginated,
},
models::PersonId,
value_objects::MovieId,
ports::{SearchCommand, SearchPort},
};
use sqlx::PgPool;
pub struct PostgresSearchAdapter {
pool: PgPool,
}
impl PostgresSearchAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
let adapter = Arc::new(PostgresSearchAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SearchCommand for PostgresSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
match doc {
IndexableDocument::Movie { id, movie, profile } => {
let movie_id = id.value().to_string();
let title = movie.title().value().to_string();
let director = movie.director().unwrap_or("").to_string();
let (overview, genres, keywords, cast_names, crew_names) =
match profile.as_deref() {
Some(p) => (
p.overview.clone().unwrap_or_default(),
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "),
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "),
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
),
None => (String::new(), String::new(), String::new(), String::new(), String::new()),
};
let fts_input = format!(
"{} {} {} {} {} {} {}",
title, director, overview, genres, keywords, cast_names, crew_names
);
sqlx::query(
"INSERT INTO movies_search (movie_id, fts)
VALUES ($1, to_tsvector('english', $2))
ON CONFLICT (movie_id) DO UPDATE SET fts = EXCLUDED.fts",
)
.bind(&movie_id)
.bind(&fts_input)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
IndexableDocument::Person { id, person } => {
let person_id = id.value().to_string();
let fts_input = format!(
"{} {}",
person.name(),
person.known_for_department().unwrap_or("")
);
sqlx::query(
"INSERT INTO people_search (person_id, fts)
VALUES ($1, to_tsvector('english', $2))
ON CONFLICT (person_id) DO UPDATE SET fts = EXCLUDED.fts",
)
.bind(&person_id)
.bind(&fts_input)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
}
}
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> {
match entity_type {
EntityType::Movie => {
sqlx::query("DELETE FROM movies_search WHERE movie_id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
}
EntityType::Person => {
sqlx::query("DELETE FROM people_search WHERE person_id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
}
}
Ok(())
}
}
#[async_trait]
impl SearchPort for PostgresSearchAdapter {
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError> {
let movies = self.search_movies(query).await?;
let people = self.search_people(query).await?;
Ok(SearchResults { movies, people })
}
}
impl PostgresSearchAdapter {
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> {
let limit = query.page.limit as i64;
let offset = query.page.offset as i64;
#[derive(sqlx::FromRow)]
struct Row {
id: String,
title: String,
release_year: Option<i32>,
director: Option<String>,
poster_path: Option<String>,
genres: Option<String>,
}
let total: u64 = if let Some(text) = &query.text {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT m.id)
FROM movies_search ms
JOIN movies m ON m.id = ms.movie_id
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE ms.fts @@ plainto_tsquery('english', $1)
AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2))
AND ($3::INT IS NULL OR m.release_year = $3)",
)
.bind(text)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
} else {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT m.id) FROM movies m
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1))
AND ($2::INT IS NULL OR m.release_year = $2)",
)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
};
let rows: Vec<Row> = if let Some(text) = &query.text {
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres
FROM movies_search ms
JOIN movies m ON m.id = ms.movie_id
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE ms.fts @@ plainto_tsquery('english', $1)
AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2))
AND ($3::INT IS NULL OR m.release_year = $3)
GROUP BY m.id, m.title, m.release_year, m.director, m.poster_path, ms.fts
ORDER BY ts_rank(ms.fts, plainto_tsquery('english', $1)) DESC
LIMIT $4 OFFSET $5",
)
.bind(text)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i32))
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
} else {
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres
FROM movies m
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1))
AND ($2::INT IS NULL OR m.release_year = $2)
GROUP BY m.id ORDER BY m.title LIMIT $3 OFFSET $4",
)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i32))
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
};
let items = rows.into_iter().map(|r| MovieSearchHit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
director: r.director,
poster_path: r.poster_path,
genres: r.genres
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
}).collect::<Vec<_>>();
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
}
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> {
let Some(text) = &query.text else {
return Ok(Paginated {
items: vec![],
total_count: 0,
limit: query.page.limit,
offset: query.page.offset,
});
};
let limit = query.page.limit as i64;
let offset = query.page.offset as i64;
let total: u64 = {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM people_search WHERE fts @@ plainto_tsquery('english', $1)",
)
.bind(text)
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
};
#[derive(sqlx::FromRow)]
struct Row {
person_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
tmdb_person_id: Option<i64>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT ps.person_id, p.name, p.known_for_department, p.profile_path, p.tmdb_person_id
FROM people_search ps
JOIN persons p ON p.id = ps.person_id
WHERE ps.fts @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(ps.fts, plainto_tsquery('english', $1)) DESC
LIMIT $2 OFFSET $3",
)
.bind(text)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
let mut items = Vec::with_capacity(rows.len());
for row in rows {
let known_for_titles = if let Some(tid) = row.tmdb_person_id {
sqlx::query_scalar::<_, String>(
"SELECT m.title FROM movie_cast mc
JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = $1
ORDER BY mc.billing_order
LIMIT 3",
)
.bind(tid)
.fetch_all(&self.pool)
.await
.unwrap_or_default()
} else {
vec![]
};
items.push(PersonSearchHit {
person_id: PersonId::from_uuid(
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default()
),
name: row.name,
known_for_department: row.known_for_department,
profile_path: row.profile_path,
known_for_titles,
});
}
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
}
}

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL UNIQUE,
tmdb_person_id BIGINT UNIQUE,
name TEXT NOT NULL,
known_for_department TEXT,
profile_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_persons_external ON persons (external_id);
CREATE INDEX IF NOT EXISTS idx_persons_tmdb_id ON persons (tmdb_person_id);
-- tsvector-based search for movies (equivalent of SQLite FTS5)
CREATE TABLE IF NOT EXISTS movies_search (
movie_id TEXT PRIMARY KEY REFERENCES movies(id) ON DELETE CASCADE,
fts TSVECTOR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_movies_search_fts ON movies_search USING GIN(fts);
-- tsvector-based search for people
CREATE TABLE IF NOT EXISTS people_search (
person_id TEXT PRIMARY KEY REFERENCES persons(id) ON DELETE CASCADE,
fts TSVECTOR NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_people_search_fts ON people_search USING GIN(fts);

View File

@@ -16,6 +16,7 @@ mod image_ref;
mod import_profile;
mod import_session;
mod models;
mod persons;
mod profile;
mod users;
@@ -27,6 +28,7 @@ use models::{
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
pub use import_profile::PostgresImportProfileRepository;
pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use users::PostgresUserRepository;

View File

@@ -0,0 +1,198 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
use sqlx::PgPool;
use std::sync::Arc;
pub struct PostgresPersonAdapter {
pool: PgPool,
}
impl PostgresPersonAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(PostgresPersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl PersonCommand for PostgresPersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
for person in persons {
let tmdb_id = person.external_id().tmdb_id();
sqlx::query(
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET
external_id = EXCLUDED.external_id,
tmdb_person_id = EXCLUDED.tmdb_person_id,
name = EXCLUDED.name,
known_for_department = EXCLUDED.known_for_department,
profile_path = EXCLUDED.profile_path",
)
.bind(person.id().value().to_string())
.bind(person.external_id().value())
.bind(tmdb_id)
.bind(person.name())
.bind(person.known_for_department())
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(map_err)?;
}
Ok(())
}
}
#[async_trait]
impl PersonQuery for PostgresPersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
}
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = $1",
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let person = self.get_by_id(id).await?.ok_or_else(|| {
DomainError::NotFound(format!("Person {} not found", id.value()))
})?;
let tmdb_id: Option<i64> = sqlx::query_scalar(
"SELECT tmdb_person_id FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
};
#[derive(sqlx::FromRow)]
struct CastRow {
id: String,
title: String,
release_year: Option<i32>,
character: String,
poster_path: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CrewRow {
id: String,
title: String,
release_year: Option<i32>,
job: String,
department: String,
poster_path: Option<String>,
}
let cast = sqlx::query_as::<_, CastRow>(
"SELECT m.id, m.title, m.release_year, mc.character, m.poster_path
FROM movie_cast mc JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = $1 ORDER BY mc.billing_order",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
character: r.character,
poster_path: r.poster_path,
})
.collect();
let crew = sqlx::query_as::<_, CrewRow>(
"SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path
FROM movie_crew mc JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = $1 ORDER BY m.title",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
job: r.job,
department: r.department,
poster_path: r.poster_path,
})
.collect();
Ok(PersonCredits { person, cast, crew })
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "sqlite-search"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,356 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchQuery, SearchResults,
collections::Paginated,
},
models::PersonId,
value_objects::MovieId,
ports::{SearchCommand, SearchPort},
};
use sqlx::SqlitePool;
pub struct SqliteSearchAdapter {
pool: SqlitePool,
}
impl SqliteSearchAdapter {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<dyn SearchPort>) {
let adapter = Arc::new(SqliteSearchAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn SearchCommand>, adapter as Arc<dyn SearchPort>)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SearchCommand for SqliteSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
match doc {
IndexableDocument::Movie { id, movie, profile } => {
let movie_id = id.value().to_string();
let title = movie.title().value().to_string();
let director = movie.director().unwrap_or("").to_string();
let release_year = movie.release_year().value() as i64;
let (overview, genres, keywords, cast_names, crew_names, language) =
match profile.as_deref() {
Some(p) => (
p.overview.clone().unwrap_or_default(),
p.genres.iter().map(|g| g.name.as_str()).collect::<Vec<_>>().join(" "),
p.keywords.iter().map(|k| k.name.as_str()).collect::<Vec<_>>().join(" "),
p.cast.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
p.crew.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(" "),
p.original_language.clone().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new(), String::new(), String::new(), String::new()),
};
sqlx::query(
"DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)",
)
.bind(&movie_id)
.execute(&self.pool)
.await
.map_err(map_err)?;
sqlx::query(
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&movie_id)
.bind(&title)
.bind(&director)
.bind(&overview)
.bind(&genres)
.bind(&keywords)
.bind(&cast_names)
.bind(&crew_names)
.bind(release_year)
.bind(&language)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
IndexableDocument::Person { id, person } => {
let person_id = id.value().to_string();
sqlx::query(
"DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)",
)
.bind(&person_id)
.execute(&self.pool)
.await
.map_err(map_err)?;
sqlx::query(
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
)
.bind(&person_id)
.bind(person.name())
.bind(person.known_for_department())
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
}
}
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> {
match entity_type {
EntityType::Movie => {
sqlx::query(
"DELETE FROM movies_fts WHERE rowid = (SELECT rowid FROM movies_fts WHERE movie_id = ? LIMIT 1)",
)
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
}
EntityType::Person => {
sqlx::query(
"DELETE FROM people_fts WHERE rowid = (SELECT rowid FROM people_fts WHERE person_id = ? LIMIT 1)",
)
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
}
}
Ok(())
}
}
#[async_trait]
impl SearchPort for SqliteSearchAdapter {
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError> {
let movies = self.search_movies(query).await?;
let people = self.search_people(query).await?;
Ok(SearchResults { movies, people })
}
}
impl SqliteSearchAdapter {
async fn search_movies(&self, query: &SearchQuery) -> Result<Paginated<MovieSearchHit>, DomainError> {
let limit = query.page.limit as i64;
let offset = query.page.offset as i64;
#[derive(sqlx::FromRow)]
struct Row {
id: String,
title: String,
release_year: Option<i64>,
director: Option<String>,
poster_path: Option<String>,
genres: Option<String>,
}
let total: u64 = if let Some(text) = &query.text {
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT m.id)
FROM movies_fts fts
JOIN movies m ON m.id = fts.movie_id
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE movies_fts MATCH ?
AND (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
AND (? IS NULL OR m.release_year = ?)",
)
.bind(&fts_query)
.bind(&query.filters.genre)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i64))
.bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
} else {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT m.id)
FROM movies m
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
AND (? IS NULL OR m.release_year = ?)",
)
.bind(&query.filters.genre)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i64))
.bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
};
let rows: Vec<Row> = if let Some(text) = &query.text {
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
GROUP_CONCAT(DISTINCT mg.name) AS genres
FROM movies_fts fts
JOIN movies m ON m.id = fts.movie_id
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE movies_fts MATCH ?
AND (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
AND (? IS NULL OR m.release_year = ?)
GROUP BY m.id
ORDER BY rank
LIMIT ? OFFSET ?",
)
.bind(&fts_query)
.bind(&query.filters.genre)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i64))
.bind(query.filters.year.map(|y| y as i64))
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
} else {
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
GROUP_CONCAT(DISTINCT mg.name) AS genres
FROM movies m
LEFT JOIN movie_genres mg ON mg.movie_id = m.id
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = ?))
AND (? IS NULL OR m.release_year = ?)
GROUP BY m.id
ORDER BY m.title
LIMIT ? OFFSET ?",
)
.bind(&query.filters.genre)
.bind(&query.filters.genre)
.bind(query.filters.year.map(|y| y as i64))
.bind(query.filters.year.map(|y| y as i64))
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
};
let items = rows.into_iter().map(|r| MovieSearchHit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
director: r.director,
poster_path: r.poster_path,
genres: r.genres
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
}).collect::<Vec<_>>();
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
}
async fn search_people(&self, query: &SearchQuery) -> Result<Paginated<PersonSearchHit>, DomainError> {
let Some(text) = &query.text else {
return Ok(Paginated {
items: vec![],
total_count: 0,
limit: query.page.limit,
offset: query.page.offset,
});
};
let limit = query.page.limit as i64;
let offset = query.page.offset as i64;
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
let total: u64 = {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM people_fts WHERE people_fts MATCH ?",
)
.bind(&fts_query)
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
count as u64
};
#[derive(sqlx::FromRow)]
struct Row {
person_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT fts.person_id, p.name, p.known_for_department, p.profile_path
FROM people_fts fts
JOIN persons p ON p.id = fts.person_id
WHERE people_fts MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?",
)
.bind(&fts_query)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
let mut items = Vec::with_capacity(rows.len());
for row in rows {
let tmdb_id: Option<i64> = sqlx::query_scalar(
"SELECT tmdb_person_id FROM persons WHERE id = ?",
)
.bind(&row.person_id)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let known_for_titles = if let Some(tid) = tmdb_id {
sqlx::query_scalar::<_, String>(
"SELECT m.title FROM movie_cast mc
JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = ?
ORDER BY mc.billing_order
LIMIT 3",
)
.bind(tid)
.fetch_all(&self.pool)
.await
.unwrap_or_default()
} else {
vec![]
};
items.push(PersonSearchHit {
person_id: PersonId::from_uuid(
uuid::Uuid::parse_str(&row.person_id).unwrap_or_default()
),
name: row.name,
known_for_department: row.known_for_department,
profile_path: row.profile_path,
known_for_titles,
});
}
Ok(Paginated { items, total_count: total, limit: query.page.limit, offset: query.page.offset })
}
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -0,0 +1,157 @@
use super::{SqliteSearchAdapter, create_search_adapter};
use domain::{
models::{
EntityType, IndexableDocument, Movie,
Person, PersonId, SearchFilters, SearchQuery,
ExternalPersonId,
collections::PageParams,
},
value_objects::{MovieId, MovieTitle, ReleaseYear},
ports::{SearchCommand, SearchPort},
};
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
release_year INTEGER, director TEXT, poster_path TEXT, external_metadata_id TEXT)",
)
.execute(&pool).await.unwrap();
sqlx::query(
"CREATE TABLE persons (id TEXT PRIMARY KEY, external_id TEXT UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT)",
)
.execute(&pool).await.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
)
.execute(&pool).await.unwrap();
sqlx::query(
"CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)",
)
.execute(&pool).await.unwrap();
sqlx::query(
"CREATE VIRTUAL TABLE movies_fts USING fts5(
movie_id UNINDEXED, title, director, overview, genres, keywords,
cast_names, crew_names, release_year UNINDEXED, language UNINDEXED)",
)
.execute(&pool).await.unwrap();
sqlx::query(
"CREATE VIRTUAL TABLE people_fts USING fts5(
person_id UNINDEXED, name, known_for_department UNINDEXED)",
)
.execute(&pool).await.unwrap();
pool
}
fn test_movie(id: &str, title: &str, year: u16) -> Movie {
Movie::from_persistence(
MovieId::from_uuid(uuid::Uuid::parse_str(id).unwrap()),
None,
MovieTitle::new(title.into()).unwrap(),
ReleaseYear::new(year).unwrap(),
Some("Test Director".to_string()),
None,
)
}
fn default_page() -> PageParams {
PageParams::new(Some(10), Some(0)).unwrap()
}
#[tokio::test]
async fn index_and_search_movie_by_title() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000001";
let movie = test_movie(id_str, "Interstellar", 2014);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("Interstellar").bind(2014i32)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
.execute(&pool).await.unwrap();
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None })
.await.unwrap();
let results = query.search(&SearchQuery {
text: Some("Interstellar".to_string()),
filters: SearchFilters::default(),
page: default_page(),
}).await.unwrap();
assert_eq!(results.movies.items.len(), 1);
assert_eq!(results.movies.items[0].title, "Interstellar");
}
#[tokio::test]
async fn remove_movie_clears_from_index() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000002";
let movie = test_movie(id_str, "Inception", 2010);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("Inception").bind(2010i32)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
.execute(&pool).await.unwrap();
cmd.index(IndexableDocument::Movie { id: movie_id.clone(), movie: Box::new(movie), profile: None })
.await.unwrap();
cmd.remove(EntityType::Movie, id_str).await.unwrap();
let results = query.search(&SearchQuery {
text: Some("Inception".to_string()),
filters: SearchFilters::default(),
page: default_page(),
}).await.unwrap();
assert!(results.movies.items.is_empty());
}
#[tokio::test]
async fn search_with_genre_filter() {
let pool = pool_with_schema().await;
let (cmd, query) = create_search_adapter(pool.clone());
let id_str = "00000000-0000-0000-0000-000000000003";
let movie = test_movie(id_str, "The Dark Knight", 2008);
let movie_id = movie.id().clone();
sqlx::query("INSERT INTO movies VALUES (?, ?, ?, ?, ?, ?)")
.bind(id_str).bind("The Dark Knight").bind(2008i32)
.bind("Christopher Nolan").bind::<Option<String>>(None).bind::<Option<String>>(None)
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movie_genres VALUES (?, 1, 'Action')")
.bind(id_str)
.execute(&pool).await.unwrap();
cmd.index(IndexableDocument::Movie {
id: movie_id.clone(),
movie: Box::new(movie),
profile: None,
}).await.unwrap();
// Matching genre — no text filter
let results = query.search(&SearchQuery {
text: None,
filters: SearchFilters { genre: Some("Action".to_string()), ..Default::default() },
page: default_page(),
}).await.unwrap();
assert_eq!(results.movies.items.len(), 1);
// Non-matching genre
let results = query.search(&SearchQuery {
text: None,
filters: SearchFilters { genre: Some("Comedy".to_string()), ..Default::default() },
page: default_page(),
}).await.unwrap();
assert!(results.movies.items.is_empty());
}

View File

@@ -0,0 +1,36 @@
-- Persons table. tmdb_person_id is stored for efficient joins with existing
-- movie_cast and movie_crew tables (which use tmdb_person_id as their person key).
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY, -- UUID (PersonId)
external_id TEXT NOT NULL UNIQUE, -- "tmdb:12345"
tmdb_person_id INTEGER UNIQUE, -- parsed from external_id for fast joins
name TEXT NOT NULL,
known_for_department TEXT,
profile_path TEXT
);
CREATE INDEX IF NOT EXISTS idx_persons_external ON persons (external_id);
CREATE INDEX IF NOT EXISTS idx_persons_tmdb_id ON persons (tmdb_person_id);
-- FTS5 full-text search table for movies.
-- movie_id is UNINDEXED (stored but not tokenised for text search).
-- release_year and language are UNINDEXED (used only for structured filters).
CREATE VIRTUAL TABLE IF NOT EXISTS movies_fts USING fts5(
movie_id UNINDEXED,
title,
director,
overview,
genres,
keywords,
cast_names,
crew_names,
release_year UNINDEXED,
language UNINDEXED
);
-- FTS5 full-text search table for people.
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
person_id UNINDEXED,
name,
known_for_department UNINDEXED
);

View File

@@ -17,6 +17,7 @@ mod import_profile;
mod import_session;
mod migrations;
mod models;
mod persons;
mod profile;
mod users;
@@ -28,6 +29,7 @@ use models::{
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use users::SqliteUserRepository;

View File

@@ -0,0 +1,195 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
use sqlx::SqlitePool;
use std::sync::Arc;
pub struct SqlitePersonAdapter {
pool: SqlitePool,
}
impl SqlitePersonAdapter {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(SqlitePersonAdapter::new(pool));
(Arc::clone(&adapter) as Arc<dyn PersonCommand>, adapter as Arc<dyn PersonQuery>)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl PersonCommand for SqlitePersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
for person in persons {
let tmdb_id = person.external_id().tmdb_id();
sqlx::query(
"INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
external_id = excluded.external_id,
tmdb_person_id = excluded.tmdb_person_id,
name = excluded.name,
known_for_department = excluded.known_for_department,
profile_path = excluded.profile_path",
)
.bind(person.id().value().to_string())
.bind(person.external_id().value())
.bind(tmdb_id)
.bind(person.name())
.bind(person.known_for_department())
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(map_err)?;
}
Ok(())
}
}
#[async_trait]
impl PersonQuery for SqlitePersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(PersonRow::into_person))
}
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError> {
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?",
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(PersonRow::into_person))
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let person = self.get_by_id(id).await?.ok_or_else(|| {
DomainError::NotFound(format!("Person {} not found", id.value()))
})?;
let tmdb_id: Option<i64> = sqlx::query_scalar(
"SELECT tmdb_person_id FROM persons WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
return Ok(PersonCredits { person, cast: vec![], crew: vec![] });
};
let cast = sqlx::query_as::<_, CastRow>(
"SELECT m.id, m.title, m.release_year, mc.character, m.poster_path
FROM movie_cast mc
JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = ?
ORDER BY mc.billing_order",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
character: r.character,
poster_path: r.poster_path,
})
.collect();
let crew = sqlx::query_as::<_, CrewRow>(
"SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path
FROM movie_crew mc
JOIN movies m ON m.id = mc.movie_id
WHERE mc.tmdb_person_id = ?
ORDER BY m.title",
)
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.into_iter()
.map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
title: r.title,
release_year: r.release_year.map(|y| y as u16),
job: r.job,
department: r.department,
poster_path: r.poster_path,
})
.collect();
Ok(PersonCredits { person, cast, crew })
}
}
// ── Row types ────────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct PersonRow {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
impl PersonRow {
fn into_person(self) -> Person {
let ext = ExternalPersonId::new(self.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
ext,
self.name,
self.known_for_department,
self.profile_path,
)
}
}
#[derive(sqlx::FromRow)]
struct CastRow {
id: String,
title: String,
release_year: Option<i64>,
character: String,
poster_path: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CrewRow {
id: String,
title: String,
release_year: Option<i64>,
job: String,
department: String,
poster_path: Option<String>,
}
#[cfg(test)]
#[path = "tests/persons.rs"]
mod tests;

View File

@@ -0,0 +1,126 @@
use super::super::persons::SqlitePersonAdapter;
use domain::{
errors::DomainError,
models::{ExternalPersonId, Person, PersonId},
ports::{PersonCommand, PersonQuery},
};
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE persons (
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movies (id TEXT PRIMARY KEY, title TEXT NOT NULL,
release_year INTEGER, director TEXT, poster_path TEXT,
external_metadata_id TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id))",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, job TEXT, department TEXT, profile_path TEXT,
PRIMARY KEY (movie_id, tmdb_person_id, job))",
)
.execute(&pool)
.await
.unwrap();
pool
}
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
Person::new(
PersonId::from_external(&ext),
ext,
name.to_string(),
dept.map(str::to_string),
None,
)
}
#[tokio::test]
async fn upsert_batch_inserts_persons() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![make_person(1, "Alice", Some("Acting")), make_person(2, "Bob", Some("Directing"))];
adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool).await.unwrap();
assert_eq!(count.0, 2);
}
#[tokio::test]
async fn upsert_batch_is_idempotent() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let persons = vec![make_person(1, "Alice", Some("Acting"))];
adapter.upsert_batch(&persons).await.unwrap();
adapter.upsert_batch(&persons).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM persons")
.fetch_one(&pool).await.unwrap();
assert_eq!(count.0, 1);
}
#[tokio::test]
async fn get_by_id_returns_person() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = make_person(42, "Charlie", Some("Acting"));
adapter.upsert_batch(&[p.clone()]).await.unwrap();
let found = adapter.get_by_id(p.id()).await.unwrap().unwrap();
assert_eq!(found.name(), "Charlie");
assert_eq!(found.external_id().value(), "tmdb:42");
}
#[tokio::test]
async fn get_by_id_returns_none_for_unknown() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool);
let ext = ExternalPersonId::new("tmdb:999");
let id = PersonId::from_external(&ext);
assert!(adapter.get_by_id(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn get_credits_returns_cast_and_crew() {
let pool = pool_with_schema().await;
let adapter = SqlitePersonAdapter::new(pool.clone());
let p = make_person(7, "Diana", Some("Acting"));
adapter.upsert_batch(&[p.clone()]).await.unwrap();
sqlx::query("INSERT INTO movies VALUES ('m1', 'The Film', 2020, 'Dir', NULL, NULL)")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movie_cast VALUES ('m1', 7, 'Diana', 'Hero', 1, NULL)")
.execute(&pool).await.unwrap();
let credits = adapter.get_credits(p.id()).await.unwrap();
assert_eq!(credits.person.name(), "Diana");
assert_eq!(credits.cast.len(), 1);
assert_eq!(credits.cast[0].character, "Hero");
assert!(credits.crew.is_empty());
}

View File

@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
application = { workspace = true }
domain = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }

View File

@@ -2,11 +2,12 @@ use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use application::{commands::EnrichMovieCommand, use_cases::enrich_movie};
use domain::{
errors::DomainError,
events::DomainEvent,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository},
ports::{EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
value_objects::MovieId,
};
use serde::Deserialize;
@@ -163,7 +164,10 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient {
pub struct EnrichmentHandler {
pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
pub profile_repo: Arc<dyn MovieProfileRepository>,
pub movie_repository: Arc<dyn MovieRepository>,
pub profile_repo: Arc<dyn MovieProfileRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub search_command: Arc<dyn SearchCommand>,
}
#[async_trait]
@@ -176,7 +180,7 @@ impl EventHandler for EnrichmentHandler {
_ => return Ok(()),
};
// Skip if profile is fresh (checked by the repo's list_stale, but guard here too)
// Skip if profile is fresh (< 30 days old)
if let Ok(Some(existing)) = self.profile_repo.get_by_movie_id(&movie_id).await {
let age = Utc::now() - existing.enriched_at;
if age.num_days() < 30 {
@@ -190,16 +194,17 @@ impl EventHandler for EnrichmentHandler {
}
tracing::info!(movie_id = %movie_id.value(), external_id = %external_metadata_id, "enriching movie");
match self.enrichment_client.fetch_profile(movie_id.clone(), &external_metadata_id).await {
Ok(profile) => {
self.profile_repo.upsert(&profile).await?;
tracing::info!(
movie_id = %movie_id.value(),
genres = profile.genres.len(),
cast = profile.cast.len(),
crew = profile.crew.len(),
"enrichment stored"
);
enrich_movie::execute(
&self.movie_repository,
&self.profile_repo,
&self.person_command,
&self.search_command,
EnrichMovieCommand { movie_id, profile },
)
.await?;
}
Err(DomainError::NotFound(msg)) => {
tracing::warn!(movie_id = %movie_id.value(), "TMDb lookup found nothing: {msg}");

View File

@@ -3,6 +3,7 @@ pub mod common;
pub mod diary;
pub mod import;
pub mod movies;
pub mod search;
pub mod social;
pub mod users;

View File

@@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct SearchQueryParams {
pub q: Option<String>,
pub genre: Option<String>,
pub year: Option<u16>,
pub person_id: Option<Uuid>,
pub department: Option<String>,
pub language: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub movies: PaginatedMovieHits,
pub people: PaginatedPersonHits,
}
#[derive(Debug, Serialize)]
pub struct PaginatedMovieHits {
pub items: Vec<MovieSearchHitDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Serialize)]
pub struct PaginatedPersonHits {
pub items: Vec<PersonSearchHitDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Serialize)]
pub struct MovieSearchHitDto {
pub movie_id: Uuid,
pub title: String,
pub release_year: Option<u16>,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct PersonSearchHitDto {
pub person_id: Uuid,
pub name: String,
pub known_for_department: Option<String>,
pub profile_path: Option<String>,
pub known_for_titles: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct PersonDto {
pub id: Uuid,
pub external_id: String,
pub name: String,
pub known_for_department: Option<String>,
pub profile_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct PersonCreditsDto {
pub person: PersonDto,
pub cast: Vec<CastCreditDto>,
pub crew: Vec<CrewCreditDto>,
}
#[derive(Debug, Serialize)]
pub struct CastCreditDto {
pub movie_id: Uuid,
pub title: String,
pub release_year: Option<u16>,
pub character: String,
pub poster_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CrewCreditDto {
pub movie_id: Uuid,
pub title: String,
pub release_year: Option<u16>,
pub job: String,
pub department: String,
pub poster_path: Option<String>,
}

View File

@@ -76,3 +76,8 @@ pub struct UpdateProfileCommand {
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}
pub struct EnrichMovieCommand {
pub movie_id: domain::value_objects::MovieId,
pub profile: domain::models::MovieProfile,
}

View File

@@ -5,6 +5,7 @@ use domain::ports::{
ImageStorage,
ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
PersonCommand, PersonQuery, SearchCommand, SearchPort,
ReviewRepository, StatsRepository, UserRepository,
};
@@ -28,5 +29,9 @@ pub struct AppContext {
pub import_session_repository: Arc<dyn ImportSessionRepository>,
pub import_profile_repository: Arc<dyn ImportProfileRepository>,
pub movie_profile_repository: Arc<dyn MovieProfileRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub config: AppConfig,
}

View File

@@ -7,3 +7,6 @@ pub mod movie_resolver;
pub mod ports;
pub mod queries;
pub mod use_cases;
pub mod search_cleanup;
pub use search_cleanup::SearchCleanupHandler;

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::EntityType,
ports::{EventHandler, SearchCommand},
};
pub struct SearchCleanupHandler {
search_command: Arc<dyn SearchCommand>,
}
impl SearchCleanupHandler {
pub fn new(search_command: Arc<dyn SearchCommand>) -> Self {
Self { search_command }
}
}
#[async_trait]
impl EventHandler for SearchCleanupHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let movie_id = match event {
DomainEvent::MovieDeleted { movie_id, .. } => movie_id.value().to_string(),
_ => return Ok(()),
};
if let Err(e) = self.search_command.remove(EntityType::Movie, &movie_id).await {
tracing::warn!("search cleanup failed for movie {movie_id}: {e}");
}
Ok(())
}
}

View File

@@ -0,0 +1,96 @@
use std::collections::HashMap;
use std::sync::Arc;
use domain::{
errors::DomainError,
models::{
CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId,
},
ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
};
use crate::commands::EnrichMovieCommand;
pub async fn execute(
movie_repository: &Arc<dyn MovieRepository>,
profile_repository: &Arc<dyn MovieProfileRepository>,
person_command: &Arc<dyn PersonCommand>,
search_command: &Arc<dyn SearchCommand>,
cmd: EnrichMovieCommand,
) -> Result<(), DomainError> {
// 1. Persist the enriched profile (also handles movie_cast, movie_crew, genres, keywords)
profile_repository.upsert(&cmd.profile).await?;
// 2. Upsert persons extracted from cast + crew (no reads — only upsert)
let persons = extract_persons(&cmd.profile.cast, &cmd.profile.crew);
if !persons.is_empty() {
person_command.upsert_batch(&persons).await?;
}
// 3. Fetch the movie for the search index document
let Some(movie) = movie_repository.get_movie_by_id(&cmd.movie_id).await? else {
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert");
return Ok(());
};
// 4. Index the movie in search
search_command
.index(IndexableDocument::Movie {
id: cmd.movie_id.clone(),
movie: Box::new(movie),
profile: Some(Box::new(cmd.profile.clone())),
})
.await?;
// 5. Index each unique person in search (no reads — persons built from in-memory data)
for person in &persons {
search_command
.index(IndexableDocument::Person {
id: person.id().clone(),
person: Box::new(person.clone()),
})
.await?;
}
tracing::info!(
movie_id = %cmd.movie_id.value(),
persons = persons.len(),
"enrich_movie: profile stored and search index updated"
);
Ok(())
}
/// Build unique Person values from cast and crew.
/// Uses deterministic UUIDv5 so the same tmdb_person_id always maps to the same PersonId.
/// No DB reads — persons are built entirely from in-memory TMDb data.
fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
let mut seen: HashMap<u64, Person> = HashMap::new();
for member in cast {
seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new(
PersonId::from_external(&ext),
ext,
member.name.clone(),
Some("Acting".to_string()),
member.profile_path.clone(),
)
});
}
for member in crew {
seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new(
PersonId::from_external(&ext),
ext,
member.name.clone(),
Some(member.department.clone()),
member.profile_path.clone(),
)
});
}
seen.into_values().collect()
}

View File

@@ -0,0 +1,6 @@
use domain::{errors::DomainError, models::{Person, PersonId}};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.person_query.get_by_id(&id).await
}

View File

@@ -0,0 +1,6 @@
use domain::{errors::DomainError, models::{PersonCredits, PersonId}};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.person_query.get_credits(&id).await
}

View File

@@ -1,3 +1,4 @@
pub mod enrich_movie;
pub mod apply_import_mapping;
pub mod apply_import_profile;
pub mod cleanup_expired_import_sessions;
@@ -12,11 +13,14 @@ pub mod get_activity_feed;
pub mod get_diary;
pub mod get_movie_social_page;
pub mod get_movies;
pub mod get_person;
pub mod get_person_credits;
pub mod get_review_history;
pub mod get_user_profile;
pub mod get_users;
pub mod log_review;
pub mod login;
pub mod register;
pub mod search;
pub mod sync_poster;
pub mod update_profile;

View File

@@ -0,0 +1,6 @@
use domain::{errors::DomainError, models::{SearchQuery, SearchResults}};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
ctx.search_port.search(&query).await
}

View File

@@ -12,6 +12,8 @@ pub mod collections;
pub mod import;
pub mod import_session;
pub mod import_profile;
pub mod person;
pub mod search;
pub use import::{
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError,
@@ -19,6 +21,11 @@ pub use import::{
};
pub use import_session::ImportSession;
pub use import_profile::ImportProfile;
pub use person::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId};
pub use search::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit,
SearchFilters, SearchQuery, SearchResults,
};
#[derive(Clone, Debug, Default)]
pub enum SortDirection {

View File

@@ -0,0 +1,107 @@
use uuid::Uuid;
use crate::models::MovieId;
#[derive(Clone, Debug, PartialEq)]
pub struct PersonId(Uuid);
impl PersonId {
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
/// Deterministic UUIDv5 from an external person ID string.
/// "tmdb:12345" always maps to the same PersonId.
pub fn from_external(external_id: &ExternalPersonId) -> Self {
Self(Uuid::new_v5(&Uuid::NAMESPACE_URL, external_id.0.as_bytes()))
}
pub fn value(&self) -> Uuid {
self.0
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExternalPersonId(String);
impl ExternalPersonId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn value(&self) -> &str {
&self.0
}
/// Parse the TMDb numeric ID from "tmdb:12345". Returns None for other formats.
pub fn tmdb_id(&self) -> Option<i64> {
self.0.strip_prefix("tmdb:").and_then(|s| s.parse().ok())
}
}
#[derive(Clone, Debug)]
pub struct Person {
id: PersonId,
external_id: ExternalPersonId,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
impl Person {
pub fn new(
id: PersonId,
external_id: ExternalPersonId,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
) -> Self {
Self { id, external_id, name, known_for_department, profile_path }
}
pub fn id(&self) -> &PersonId {
&self.id
}
pub fn external_id(&self) -> &ExternalPersonId {
&self.external_id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn known_for_department(&self) -> Option<&str> {
self.known_for_department.as_deref()
}
pub fn profile_path(&self) -> Option<&str> {
self.profile_path.as_deref()
}
}
#[derive(Clone, Debug)]
pub struct PersonCredits {
pub person: Person,
pub cast: Vec<CastCredit>,
pub crew: Vec<CrewCredit>,
}
#[derive(Clone, Debug)]
pub struct CastCredit {
pub movie_id: MovieId,
pub title: String,
pub release_year: Option<u16>,
pub character: String,
pub poster_path: Option<String>,
}
#[derive(Clone, Debug)]
pub struct CrewCredit {
pub movie_id: MovieId,
pub title: String,
pub release_year: Option<u16>,
pub job: String,
pub department: String,
pub poster_path: Option<String>,
}

View File

@@ -0,0 +1,68 @@
use crate::models::{
Movie, MovieId, MovieProfile, Person, PersonId,
collections::{PageParams, Paginated},
};
#[derive(Clone, Debug, Default)]
pub struct SearchQuery {
pub text: Option<String>,
pub filters: SearchFilters,
pub page: PageParams,
}
#[derive(Clone, Debug, Default)]
pub struct SearchFilters {
pub genre: Option<String>,
pub year: Option<u16>,
pub person_id: Option<PersonId>,
pub department: Option<String>,
pub language: Option<String>,
}
#[derive(Clone, Debug)]
pub struct SearchResults {
pub movies: Paginated<MovieSearchHit>,
pub people: Paginated<PersonSearchHit>,
}
#[derive(Clone, Debug)]
pub struct MovieSearchHit {
pub movie_id: MovieId,
pub title: String,
pub release_year: Option<u16>,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct PersonSearchHit {
pub person_id: PersonId,
pub name: String,
pub known_for_department: Option<String>,
pub profile_path: Option<String>,
/// Top movie titles this person is known for — populated at query time
/// by joining relational tables, never from the index.
pub known_for_titles: Vec<String>,
}
/// Document submitted to the search index.
/// Add a new variant here to make a new entity type searchable — the port never changes.
pub enum IndexableDocument {
Movie {
id: MovieId,
movie: Box<Movie>,
profile: Option<Box<MovieProfile>>,
},
Person {
id: PersonId,
person: Box<Person>,
// known_for_titles intentionally absent — no reads inside a command flow
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum EntityType {
Movie,
Person,
}

View File

@@ -8,6 +8,8 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
EntityType, ExternalPersonId, IndexableDocument, Person, PersonCredits,
PersonId, SearchQuery, SearchResults,
collections::{self, PageParams, Paginated},
},
value_objects::{
@@ -274,3 +276,34 @@ pub trait ImageRefCommand: Send + Sync {
pub trait ImageRefQuery: Send + Sync {
async fn list_keys(&self) -> Result<Vec<String>, DomainError>;
}
/// Write port — mutates the persons table. No reads.
#[async_trait]
pub trait PersonCommand: Send + Sync {
/// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres).
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>;
}
/// Read port — queries persons and credits. No mutations.
#[async_trait]
pub trait PersonQuery: Send + Sync {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError>;
async fn get_by_external_id(&self, id: &ExternalPersonId) -> Result<Option<Person>, DomainError>;
/// Returns the person's full cast and crew credit history across all indexed movies.
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError>;
}
/// Read port — executes search queries. No mutations.
#[async_trait]
pub trait SearchPort: Send + Sync {
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError>;
}
/// Write port — manages the search index. No reads.
#[async_trait]
pub trait SearchCommand: Send + Sync {
/// Add or replace a document in the search index.
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>;
/// Remove a document from the search index by entity type and internal ID string.
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
}

View File

@@ -5,8 +5,8 @@ edition = "2024"
[features]
default = ["sqlite", "sqlite-federation"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue"]
postgres = ["dep:postgres", "dep:postgres-event-queue"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = []
@@ -63,6 +63,8 @@ sqlite = { workspace = true, optional = true }
postgres = { workspace = true, optional = true }
sqlite-event-queue = { workspace = true, optional = true }
postgres-event-queue = { workspace = true, optional = true }
sqlite-search = { workspace = true, optional = true }
postgres-search = { workspace = true, optional = true }
# Optional — federation
activitypub = { workspace = true, optional = true }

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;

View File

@@ -10,10 +10,11 @@ use axum::{
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, User},
models::{Movie, User, PersonId, Person, PersonCredits, EntityType, IndexableDocument, SearchQuery, SearchResults, ExternalPersonId},
ports::{
AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria,
PasswordHasher, PosterFetcherClient, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId,
@@ -163,6 +164,33 @@ impl domain::ports::ImportProfileRepository for PanicImportProfile {
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
}
struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() }
}
struct PanicPersonQuery;
#[async_trait]
impl PersonQuery for PanicPersonQuery {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_by_external_id(&self, _: &ExternalPersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> { panic!() }
}
struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> { panic!() }
}
struct PanicSearchCommand;
#[async_trait]
impl SearchCommand for PanicSearchCommand {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
}
#[cfg(feature = "federation")]
struct PanicSocialQuery;
#[cfg(feature = "federation")]
@@ -207,6 +235,10 @@ async fn test_app() -> Router {
import_session_repository: Arc::new(PanicImportSession),
import_profile_repository: Arc::new(PanicImportProfile),
movie_profile_repository: Arc::new(PanicMovieProfile),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),
search_command: Arc::new(PanicSearchCommand),
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),

View File

@@ -5,8 +5,8 @@ edition = "2024"
[features]
default = ["sqlite"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue"]
postgres = ["dep:postgres", "dep:postgres-event-queue"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
federation = []
sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"]
@@ -37,6 +37,8 @@ sqlite = { workspace = true, optional = true }
postgres = { workspace = true, optional = true }
sqlite-event-queue = { workspace = true, optional = true }
postgres-event-queue = { workspace = true, optional = true }
sqlite-search = { workspace = true, optional = true }
postgres-search = { workspace = true, optional = true }
# Optional — federation
activitypub = { workspace = true, optional = true }

View File

@@ -3,8 +3,8 @@ use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository,
ImportSessionRepository, MovieProfileRepository, MovieRepository, ReviewRepository,
StatsRepository, UserRepository,
ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
};
pub enum DbPool {
@@ -24,7 +24,11 @@ pub struct Repos {
pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_command: Arc<dyn SearchCommand>,
pub search_port: Arc<dyn SearchPort>,
}
pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> {
@@ -34,18 +38,26 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos
let (pool, m, r, d, s, u, is, ip, mp) =
postgres::wire(database_url).await.context("PostgreSQL connection failed")?;
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone());
let (person_command, person_query) = postgres::create_person_adapter(pool.clone());
let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
image_ref_command, image_ref_query }, DbPool::Postgres(pool)))
image_ref_command, image_ref_query,
person_command, person_query, search_command, search_port },
DbPool::Postgres(pool)))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp) =
sqlite::wire(database_url).await.context("SQLite connection failed")?;
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone());
let (person_command, person_query) = sqlite::create_person_adapter(pool.clone());
let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
image_ref_command, image_ref_query }, DbPool::Sqlite(pool)))
image_ref_command, image_ref_query,
person_command, person_query, search_command, search_port },
DbPool::Sqlite(pool)))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"),

View File

@@ -4,7 +4,7 @@ mod event_bus;
use std::sync::Arc;
use anyhow::Context;
use application::{config::AppConfig, context::AppContext, worker::WorkerService};
use application::{config::AppConfig, context::AppContext, worker::WorkerService, SearchCleanupHandler};
use export::ExportAdapter;
use importer::ImporterDocumentParser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -32,7 +32,11 @@ async fn main() -> anyhow::Result<()> {
let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?;
let image_ref_command = Arc::clone(&repos.image_ref_command);
let image_ref_query = Arc::clone(&repos.image_ref_query);
let image_ref_query = Arc::clone(&repos.image_ref_query);
let person_command = Arc::clone(&repos.person_command);
let person_query = Arc::clone(&repos.person_query);
let search_command = Arc::clone(&repos.search_command);
let search_port = Arc::clone(&repos.search_port);
// Clone refs federation handler needs before ctx consumes them.
#[cfg(feature = "federation")]
@@ -62,6 +66,10 @@ async fn main() -> anyhow::Result<()> {
import_session_repository: repos.import_session,
import_profile_repository: repos.import_profile,
movie_profile_repository: repos.movie_profile,
person_command: Arc::clone(&person_command),
person_query: Arc::clone(&person_query),
search_port: Arc::clone(&search_port),
search_command: Arc::clone(&search_command),
config: app_config,
};
@@ -75,7 +83,10 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
enrichment_client: Arc::new(client),
profile_repo: Arc::clone(&ctx.movie_profile_repository),
movie_repository: Arc::clone(&ctx.movie_repository),
profile_repo: Arc::clone(&ctx.movie_profile_repository),
person_command: Arc::clone(&ctx.person_command),
search_command: Arc::clone(&ctx.search_command),
}) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>;
@@ -134,7 +145,10 @@ async fn main() -> anyhow::Result<()> {
#[cfg(not(feature = "federation"))]
{
let mut h = vec![poster, cleanup];
let search_cleanup = Arc::new(SearchCleanupHandler::new(
Arc::clone(&ctx.search_command),
)) as Arc<dyn EventHandler>;
let mut h = vec![poster, cleanup, search_cleanup];
if let Some(e) = enrichment_handler { h.push(e); }
if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); }
h
@@ -160,8 +174,11 @@ async fn main() -> anyhow::Result<()> {
allow_registration,
).await?.event_handler;
let search_cleanup = Arc::new(SearchCleanupHandler::new(
Arc::clone(&ctx.search_command),
)) as Arc<dyn EventHandler>;
tracing::info!("federation event handler registered");
let mut h = vec![poster, cleanup, ap];
let mut h = vec![poster, cleanup, ap, search_cleanup];
if let Some(e) = enrichment_handler { h.push(e); }
if let Some((ref conv_handler, _)) = conversion { h.push(Arc::clone(conv_handler)); }
h