feat: search reindex, worker improvements, person IDs, user display names
- add admin POST /api/v1/admin/reindex-search endpoint + event-driven handler - backfill persons from movie_cast/movie_crew into persons table - paginate person list_page/backfill_from_credits_batch to cap memory - concurrent worker event dispatch with semaphore (max 8) - graceful worker shutdown (drain in-flight tasks on SIGINT) - always ack events, log handler errors as warnings (no infinite retry) - NATS ack_wait 600s, AtomicBool guard against concurrent reindex - add username/display_name to UserSummaryDto and users list - add person_id to CastMemberDto/CrewMemberDto via get_movie_profile use case - add movie_id to wrapup MovieRef, person_id to wrapup PersonStat - thread tmdb_person_id through wrapup cast pipeline - add is_federated to FeedEntryDto - cap orphaned persons query with LIMIT 500 - add SPA link to classic site footer
This commit is contained in:
@@ -7,7 +7,7 @@ use domain::{
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
ReviewId, UserId, Username,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -237,6 +237,8 @@ impl MovieStatsRow {
|
||||
pub(crate) struct UserSummaryRow {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub total_movies: i64,
|
||||
pub avg_rating: Option<f64>,
|
||||
pub avatar_path: Option<String>,
|
||||
@@ -247,6 +249,8 @@ impl UserSummaryRow {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&self.id)?),
|
||||
Email::new(self.email)?,
|
||||
Username::new(self.username)?,
|
||||
self.display_name,
|
||||
self.total_movies,
|
||||
self.avg_rating,
|
||||
self.avatar_path,
|
||||
|
||||
@@ -57,6 +57,60 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
batch_size: u32,
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MissingPerson {
|
||||
tmdb_person_id: i64,
|
||||
name: String,
|
||||
department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, MissingPerson>(
|
||||
"SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path
|
||||
FROM movie_cast mc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
|
||||
GROUP BY mc.tmdb_person_id, mc.name, mc.profile_path
|
||||
UNION ALL
|
||||
SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path
|
||||
FROM movie_crew mc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id)
|
||||
GROUP BY mc.tmdb_person_id, mc.name, mc.department, mc.profile_path
|
||||
LIMIT $1",
|
||||
)
|
||||
.bind(batch_size as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
let has_more = rows.len() as u32 >= batch_size;
|
||||
let mut count = 0u64;
|
||||
for row in &rows {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id));
|
||||
let pid = PersonId::from_external(&ext);
|
||||
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(tmdb_person_id) DO NOTHING",
|
||||
)
|
||||
.bind(pid.value().to_string())
|
||||
.bind(ext.value())
|
||||
.bind(row.tmdb_person_id)
|
||||
.bind(&row.name)
|
||||
.bind(&row.department)
|
||||
.bind(&row.profile_path)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok((count, has_more))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -206,6 +260,40 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
Ok(PersonCredits { person, cast, crew })
|
||||
}
|
||||
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
external_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.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,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM persons
|
||||
@@ -214,7 +302,8 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id
|
||||
)",
|
||||
)
|
||||
LIMIT 500",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
|
||||
@@ -186,13 +186,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
sqlx::query_as::<_, UserSummaryRow>(
|
||||
r#"SELECT u.id, u.email,
|
||||
r#"SELECT u.id, u.email, u.username, u.display_name,
|
||||
COUNT(DISTINCT r.movie_id) AS total_movies,
|
||||
AVG(r.rating::float) AS avg_rating,
|
||||
u.avatar_path
|
||||
FROM users u
|
||||
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
|
||||
GROUP BY u.id, u.email, u.avatar_path
|
||||
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
|
||||
ORDER BY u.email ASC"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
|
||||
@@ -333,9 +333,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
let cast = cast_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
|
||||
let cast_names: Vec<(String, u32)> = cast
|
||||
let cast_names: Vec<(String, u32, i64)> = cast
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.billing_order))
|
||||
.map(|c| (c.name.clone(), c.billing_order, c.tmdb_person_id))
|
||||
.collect();
|
||||
let cast_profile_paths: Vec<Option<String>> =
|
||||
cast.iter().map(|c| c.profile_path.clone()).collect();
|
||||
@@ -367,6 +367,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
struct CastEntry {
|
||||
name: String,
|
||||
billing_order: u32,
|
||||
tmdb_person_id: i64,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
@@ -417,7 +418,7 @@ async fn fetch_cast_pg(
|
||||
movie_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<CastEntry>>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT movie_id, name, billing_order, profile_path \
|
||||
"SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \
|
||||
FROM movie_cast \
|
||||
WHERE movie_id = ANY($1) AND billing_order <= 3 \
|
||||
ORDER BY billing_order ASC",
|
||||
@@ -432,10 +433,12 @@ async fn fetch_cast_pg(
|
||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let name: String = row.try_get("name").map_err(map_err)?;
|
||||
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?;
|
||||
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?;
|
||||
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?;
|
||||
map.entry(mid).or_default().push(CastEntry {
|
||||
name,
|
||||
billing_order: billing_order as u32,
|
||||
tmdb_person_id,
|
||||
profile_path,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user