diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index 006d0be..0ce3005 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -275,3 +275,12 @@ pub(crate) struct WatchMediumCountRow { pub watch_medium: String, pub count: i64, } + +#[derive(sqlx::FromRow)] +pub(crate) struct ActorAppearanceRow { + pub tmdb_person_id: i64, + pub name: String, + pub profile_path: Option, + pub billing_order: i32, + pub movie_id: String, +} diff --git a/crates/adapters/postgres/src/stats.rs b/crates/adapters/postgres/src/stats.rs index 482c26d..5b19068 100644 --- a/crates/adapters/postgres/src/stats.rs +++ b/crates/adapters/postgres/src/stats.rs @@ -1,15 +1,17 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{DirectorStat, MonthlyRating, UserStats, UserTrends}, + models::{ + ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors, + }, ports::StatsRepository, value_objects::UserId, }; use sqlx::PgPool; use crate::models::{ - DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, - WatchMediumCountRow, + ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, + UserTotalsRow, WatchMediumCountRow, }; use adapter_common::format_year_month; @@ -100,7 +102,7 @@ impl StatsRepository for PostgresStatsRepository { async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) = + let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) = tokio::try_join!( sqlx::query_as::<_, MonthlyRatingRow>( "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, @@ -152,6 +154,16 @@ impl StatsRepository for PostgresStatsRepository { ORDER BY COUNT(*) DESC" ) .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, ActorAppearanceRow>( + "SELECT mc.tmdb_person_id, mc.name, mc.profile_path, + mc.billing_order, mc.movie_id + FROM reviews r + INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id + WHERE r.user_id = $1 + GROUP BY mc.tmdb_person_id, mc.movie_id, mc.name, mc.profile_path, mc.billing_order" + ) + .bind(&uid) .fetch_all(&self.pool) ) .map_err(adapter_common::map_sqlx_error)?; @@ -201,6 +213,19 @@ impl StatsRepository for PostgresStatsRepository { }) .collect(); + let top_actors = compute_top_actors( + actor_rows + .into_iter() + .map(|r| ActorAppearance { + tmdb_person_id: r.tmdb_person_id as u64, + name: r.name, + profile_path: r.profile_path, + billing_order: r.billing_order as u32, + movie_id: r.movie_id, + }) + .collect(), + ); + Ok(UserTrends { monthly_ratings, top_directors, @@ -208,6 +233,7 @@ impl StatsRepository for PostgresStatsRepository { top_genres, rating_distribution, watch_medium_distribution, + top_actors, }) } } diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index 68ccd8d..9a021bc 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -282,6 +282,15 @@ pub(crate) struct WatchMediumCountRow { pub count: i64, } +#[derive(sqlx::FromRow)] +pub(crate) struct ActorAppearanceRow { + pub tmdb_person_id: i64, + pub name: String, + pub profile_path: Option, + pub billing_order: i32, + pub movie_id: String, +} + #[derive(sqlx::FromRow)] pub(crate) struct WatchlistRow { pub id: String, diff --git a/crates/adapters/sqlite/src/stats.rs b/crates/adapters/sqlite/src/stats.rs index 9f74190..937ebd0 100644 --- a/crates/adapters/sqlite/src/stats.rs +++ b/crates/adapters/sqlite/src/stats.rs @@ -1,15 +1,17 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{DirectorStat, MonthlyRating, UserStats, UserTrends}, + models::{ + ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors, + }, ports::StatsRepository, value_objects::UserId, }; use sqlx::SqlitePool; use crate::models::{ - DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow, - WatchMediumCountRow, + ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, + UserTotalsRow, WatchMediumCountRow, }; pub struct SqliteStatsRepository { @@ -101,7 +103,7 @@ impl StatsRepository for SqliteStatsRepository { async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) = + let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) = tokio::try_join!( sqlx::query_as::<_, MonthlyRatingRow>( "SELECT strftime('%Y-%m', watched_at) AS month, @@ -154,6 +156,16 @@ impl StatsRepository for SqliteStatsRepository { ORDER BY COUNT(*) DESC", ) .bind(&uid) + .fetch_all(&self.pool), + sqlx::query_as::<_, ActorAppearanceRow>( + "SELECT mc.tmdb_person_id, mc.name, mc.profile_path, + mc.billing_order, mc.movie_id + FROM reviews r + INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id + WHERE r.user_id = ? + GROUP BY mc.tmdb_person_id, mc.movie_id", + ) + .bind(&uid) .fetch_all(&self.pool) ) .map_err(adapter_common::map_sqlx_error)?; @@ -203,6 +215,19 @@ impl StatsRepository for SqliteStatsRepository { }) .collect(); + let top_actors = compute_top_actors( + actor_rows + .into_iter() + .map(|r| ActorAppearance { + tmdb_person_id: r.tmdb_person_id as u64, + name: r.name, + profile_path: r.profile_path, + billing_order: r.billing_order as u32, + movie_id: r.movie_id, + }) + .collect(), + ); + Ok(UserTrends { monthly_ratings, top_directors, @@ -210,6 +235,7 @@ impl StatsRepository for SqliteStatsRepository { top_genres, rating_distribution, watch_medium_distribution, + top_actors, }) } } diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 6894da6..320b8a7 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -60,6 +60,15 @@ pub struct DirectorStatDto { pub count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ActorStatDto { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_path: Option, + pub movie_count: u32, + pub score: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct GenreStatDto { pub genre: String, @@ -80,6 +89,7 @@ pub struct UserTrendsDto { pub top_genres: Vec, pub rating_distribution: [i64; 5], pub watch_medium_distribution: Vec, + pub top_actors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index b620941..a4aff96 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -26,7 +26,10 @@ pub use federation::*; pub use feed::*; pub use movie::*; pub use review::*; -pub use stats::{DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, UserTrends}; +pub use stats::{ + ActorAppearance, ActorStat, DirectorStat, MonthActivity, MonthlyRating, MovieStats, UserStats, + UserTrends, compute_top_actors, +}; pub use user::*; pub use goal::{Goal, GoalWithProgress}; diff --git a/crates/domain/src/models/stats.rs b/crates/domain/src/models/stats.rs index f871532..a6156b5 100644 --- a/crates/domain/src/models/stats.rs +++ b/crates/domain/src/models/stats.rs @@ -50,6 +50,85 @@ pub struct UserTrends { pub top_genres: Vec, pub rating_distribution: [i64; 5], pub watch_medium_distribution: Vec, + pub top_actors: Vec, +} + +#[derive(Clone, Debug)] +pub struct ActorAppearance { + pub tmdb_person_id: u64, + pub name: String, + pub profile_path: Option, + pub billing_order: u32, + pub movie_id: String, +} + +#[derive(Clone, Debug)] +pub struct ActorStat { + pub tmdb_person_id: u64, + pub name: String, + pub profile_path: Option, + pub movie_count: u32, + pub score: f64, +} + +const LEAD_BILLING_MAX: u32 = 2; +const SUPPORTING_BILLING_MIN: u32 = 3; +const SUPPORTING_BILLING_MAX: u32 = 9; + +const LEAD_WEIGHT: f64 = 1.0; +const SUPPORTING_WEIGHT: f64 = 0.7; +const MINOR_WEIGHT: f64 = 0.2; + +const TOP_ACTORS_LIMIT: usize = 5; + +fn billing_weight(order: u32) -> f64 { + match order { + 0..=LEAD_BILLING_MAX => LEAD_WEIGHT, + SUPPORTING_BILLING_MIN..=SUPPORTING_BILLING_MAX => SUPPORTING_WEIGHT, + _ => MINOR_WEIGHT, + } +} + +pub fn compute_top_actors(appearances: Vec) -> Vec { + use std::collections::{HashMap, HashSet}; + + struct ActorAccumulator { + name: String, + profile_path: Option, + score: f64, + seen_movies: HashSet, + } + + let mut actors_by_id: HashMap = HashMap::new(); + + for appearance in appearances { + let accumulator = actors_by_id + .entry(appearance.tmdb_person_id) + .or_insert_with(|| ActorAccumulator { + name: appearance.name.clone(), + profile_path: appearance.profile_path.clone(), + score: 0.0, + seen_movies: HashSet::new(), + }); + if accumulator.seen_movies.insert(appearance.movie_id) { + accumulator.score += billing_weight(appearance.billing_order); + } + } + + let mut ranked_actors: Vec = actors_by_id + .into_iter() + .map(|(person_id, accumulator)| ActorStat { + tmdb_person_id: person_id, + name: accumulator.name, + profile_path: accumulator.profile_path, + movie_count: accumulator.seen_movies.len() as u32, + score: accumulator.score, + }) + .collect(); + + ranked_actors.sort_by(|left, right| right.score.total_cmp(&left.score)); + ranked_actors.truncate(TOP_ACTORS_LIMIT); + ranked_actors } #[derive(Clone, Debug)] @@ -59,3 +138,7 @@ pub struct MovieStats { pub federated_count: u64, pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ } + +#[cfg(test)] +#[path = "tests/stats.rs"] +mod tests; diff --git a/crates/domain/src/models/tests/stats.rs b/crates/domain/src/models/tests/stats.rs new file mode 100644 index 0000000..345eb3a --- /dev/null +++ b/crates/domain/src/models/tests/stats.rs @@ -0,0 +1,71 @@ +use super::*; + +fn appearance(person_id: u64, name: &str, billing: u32, movie: &str) -> ActorAppearance { + ActorAppearance { + tmdb_person_id: person_id, + name: name.to_string(), + profile_path: Some(format!("/profile_{person_id}.jpg")), + billing_order: billing, + movie_id: movie.to_string(), + } +} + +#[test] +fn empty_appearances_returns_empty() { + assert!(compute_top_actors(vec![]).is_empty()); +} + +#[test] +fn lead_scores_higher_than_minor_with_same_movie_count() { + let appearances = vec![ + appearance(1, "Lead Actor", 0, "movie_a"), + appearance(1, "Lead Actor", 1, "movie_b"), + appearance(2, "Minor Actor", 15, "movie_a"), + appearance(2, "Minor Actor", 12, "movie_b"), + ]; + let result = compute_top_actors(appearances); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "Lead Actor"); + assert_eq!(result[0].movie_count, 2); + assert!(result[0].score > result[1].score); +} + +#[test] +fn same_movie_counted_once_per_actor() { + let appearances = vec![ + appearance(1, "Actor A", 0, "movie_a"), + appearance(1, "Actor A", 0, "movie_a"), + ]; + let result = compute_top_actors(appearances); + assert_eq!(result[0].movie_count, 1); +} + +#[test] +fn returns_at_most_five() { + let appearances: Vec = (0..10) + .flat_map(|i| { + (0..=i) + .map(move |m| appearance(i as u64, &format!("Actor {i}"), 0, &format!("movie_{m}"))) + }) + .collect(); + let result = compute_top_actors(appearances); + assert_eq!(result.len(), 5); +} + +#[test] +fn tier_weights_applied_correctly() { + let appearances = vec![ + appearance(1, "Lead", 0, "m1"), + appearance(2, "Support", 5, "m1"), + appearance(2, "Support", 3, "m2"), + appearance(3, "Minor", 10, "m1"), + ]; + let result = compute_top_actors(appearances); + assert_eq!(result[0].name, "Support"); + assert!((result[0].score - 1.4).abs() < 0.001); + assert_eq!(result[0].movie_count, 2); + assert_eq!(result[1].name, "Lead"); + assert!((result[1].score - 1.0).abs() < 0.001); + assert_eq!(result[2].name, "Minor"); + assert!((result[2].score - 0.2).abs() < 0.001); +} diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index 7f883ac..74148b9 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -243,6 +243,7 @@ impl StatsRepository for FakeStatsRepository { top_genres: vec![], rating_distribution: [0; 5], watch_medium_distribution: vec![], + top_actors: vec![], }) } diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index d0a4c13..e9d22b7 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -24,7 +24,7 @@ use crate::{ state::AppState, }; use api_types::{ - DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, + ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto, ProfileResponse, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchMediumStatDto, }; @@ -427,6 +427,16 @@ fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto { count: m.count, }) .collect(), + top_actors: t + .top_actors + .into_iter() + .map(|a| ActorStatDto { + name: a.name, + profile_path: a.profile_path, + movie_count: a.movie_count, + score: a.score, + }) + .collect(), } } diff --git a/spa/src/components/profile-view.tsx b/spa/src/components/profile-view.tsx index a76afe5..7bcd2ff 100644 --- a/spa/src/components/profile-view.tsx +++ b/spa/src/components/profile-view.tsx @@ -19,6 +19,7 @@ import { WATCH_MEDIUMS } from "@/lib/watch-mediums" import { ReviewDetailSheet } from "@/components/review-detail-sheet" import { DiaryCalendar } from "@/components/diary-calendar" import type { DiaryEntryDto } from "@/lib/api/common" +import { tmdbProfileUrl } from "@/lib/api/client" import type { UserProfileResponse } from "@/features/users" type ProfileViewProps = { @@ -231,6 +232,7 @@ function TrendsView({ data: { trends?: { top_directors: { director: string; count: number }[] + top_actors?: { name: string; profile_path?: string; movie_count: number; score: number }[] monthly_ratings: { month_label: string avg_rating: number @@ -269,6 +271,33 @@ function TrendsView({ )} + {data.trends.top_actors && data.trends.top_actors.length > 0 && ( + + + {t("profile.topActors")} + + + {data.trends.top_actors.map((a) => ( +
+ + {a.profile_path && ( + + )} + {a.name[0]} + + {a.name} + + {t("common.films", { count: a.movie_count })} + +
+ ))} +
+
+ )} + {data.trends.top_genres.length > 0 && ( diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index 5dcd5a2..fa370de 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -48,6 +48,14 @@ export const directorStatDtoSchema = z.object({ }) export type DirectorStatDto = z.infer +export const actorStatDtoSchema = z.object({ + name: z.string(), + profile_path: z.string().optional(), + movie_count: z.number(), + score: z.number(), +}) +export type ActorStatDto = z.infer + export const genreStatDtoSchema = z.object({ genre: z.string(), count: z.number(), @@ -64,6 +72,7 @@ export const userTrendsDtoSchema = z.object({ monthly_ratings: z.array(monthlyRatingDtoSchema), top_directors: z.array(directorStatDtoSchema), max_director_count: z.number(), + top_actors: z.array(actorStatDtoSchema).default([]), top_genres: z.array(genreStatDtoSchema).default([]), rating_distribution: z.array(z.number()).default([]), watch_medium_distribution: z.array(watchMediumStatDtoSchema).default([]), diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index 7b84332..2517a77 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -125,6 +125,7 @@ "noEntries": "No entries", "noTrends": "No trends yet", "topDirectors": "Top Directors", + "topActors": "Favorite Actors", "monthlyActivity": "Monthly Activity", "searchPlaceholder": "Search entries...", "watchedAgo": "Watched {{when}}"