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:
@@ -86,6 +86,7 @@ pub enum EventPayload {
|
||||
WrapUpCompleted {
|
||||
wrapup_id: String,
|
||||
},
|
||||
SearchReindexRequested,
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -107,6 +108,7 @@ impl EventPayload {
|
||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||
EventPayload::WrapUpRequested { .. } => "WrapUpRequested",
|
||||
EventPayload::WrapUpCompleted { .. } => "WrapUpCompleted",
|
||||
EventPayload::SearchReindexRequested => "SearchReindexRequested",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +250,7 @@ impl From<&DomainEvent> for EventPayload {
|
||||
DomainEvent::WrapUpCompleted { wrapup_id } => EventPayload::WrapUpCompleted {
|
||||
wrapup_id: wrapup_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::SearchReindexRequested => EventPayload::SearchReindexRequested,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,6 +401,7 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
wrapup_id: WrapUpId::from_uuid(wid),
|
||||
})
|
||||
}
|
||||
EventPayload::SearchReindexRequested => Ok(DomainEvent::SearchReindexRequested),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ impl NatsJetStreamConsumer {
|
||||
pull::Config {
|
||||
durable_name: Some(cfg.consumer_name.clone()),
|
||||
filter_subject: subject_filter,
|
||||
ack_wait: std::time::Duration::from_secs(600),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
|
||||
DomainEvent::WrapUpRequested { .. } => "wrapup.requested",
|
||||
DomainEvent::WrapUpCompleted { .. } => "wrapup.completed",
|
||||
DomainEvent::SearchReindexRequested => "search.reindex.requested",
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
ReviewId, UserId, Username, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -245,6 +245,8 @@ impl FeedRow {
|
||||
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>,
|
||||
@@ -255,6 +257,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 SqlitePersonAdapter {
|
||||
}
|
||||
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
|
||||
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
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(batch_size)
|
||||
.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 (?, ?, ?, ?, ?, ?)
|
||||
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]
|
||||
@@ -156,6 +210,19 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
Ok(PersonCredits { person, cast, crew })
|
||||
}
|
||||
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||
}
|
||||
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM persons
|
||||
@@ -164,7 +231,8 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
)
|
||||
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
|
||||
|
||||
@@ -182,17 +182,15 @@ impl UserRepository for SqliteUserRepository {
|
||||
}
|
||||
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
sqlx::query_as!(
|
||||
UserSummaryRow,
|
||||
r#"SELECT u.id AS "id!: String",
|
||||
u.email AS "email!: String",
|
||||
COUNT(DISTINCT r.movie_id) AS "total_movies!: i64",
|
||||
sqlx::query_as::<_, UserSummaryRow>(
|
||||
r#"SELECT u.id, u.email, u.username, u.display_name,
|
||||
COUNT(DISTINCT r.movie_id) AS total_movies,
|
||||
AVG(CAST(r.rating AS REAL)) 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
|
||||
ORDER BY u.email ASC"#
|
||||
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
|
||||
ORDER BY u.email ASC"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
|
||||
@@ -345,9 +345,9 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
|
||||
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();
|
||||
@@ -379,6 +379,7 @@ impl WrapUpStatsQuery for SqliteWrapUpStatsQuery {
|
||||
struct CastEntry {
|
||||
name: String,
|
||||
billing_order: u32,
|
||||
tmdb_person_id: i64,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
@@ -453,7 +454,7 @@ async fn fetch_cast_sqlite(
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let sql = format!(
|
||||
"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 IN ({}) AND billing_order <= 3 \
|
||||
ORDER BY billing_order ASC",
|
||||
@@ -470,10 +471,12 @@ async fn fetch_cast_sqlite(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
{% endif %}
|
||||
<span class="footer-sep">·</span>
|
||||
<a href="/docs" target="_blank" class="footer-link">API Docs</a>
|
||||
<span class="footer-sep">·</span>
|
||||
<a href="/app/" class="footer-link">Mobile App</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user