feat: favorite actors top-5 stat in profile trends
All checks were successful
CI / Check / Test (push) Successful in 1h5m14s
All checks were successful
CI / Check / Test (push) Successful in 1h5m14s
This commit is contained in:
@@ -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<String>,
|
||||
pub billing_order: i32,
|
||||
pub movie_id: String,
|
||||
}
|
||||
|
||||
@@ -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<UserTrends, DomainError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub billing_order: i32,
|
||||
pub movie_id: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct WatchlistRow {
|
||||
pub id: String,
|
||||
|
||||
@@ -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<UserTrends, DomainError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
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<GenreStatDto>,
|
||||
pub rating_distribution: [i64; 5],
|
||||
pub watch_medium_distribution: Vec<WatchMediumStatDto>,
|
||||
pub top_actors: Vec<ActorStatDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -50,6 +50,85 @@ pub struct UserTrends {
|
||||
pub top_genres: Vec<GenreStat>,
|
||||
pub rating_distribution: [i64; 5],
|
||||
pub watch_medium_distribution: Vec<WatchMediumStat>,
|
||||
pub top_actors: Vec<ActorStat>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActorAppearance {
|
||||
pub tmdb_person_id: u64,
|
||||
pub name: String,
|
||||
pub profile_path: Option<String>,
|
||||
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<String>,
|
||||
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<ActorAppearance>) -> Vec<ActorStat> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
struct ActorAccumulator {
|
||||
name: String,
|
||||
profile_path: Option<String>,
|
||||
score: f64,
|
||||
seen_movies: HashSet<String>,
|
||||
}
|
||||
|
||||
let mut actors_by_id: HashMap<u64, ActorAccumulator> = 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<ActorStat> = 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;
|
||||
|
||||
71
crates/domain/src/models/tests/stats.rs
Normal file
71
crates/domain/src/models/tests/stats.rs
Normal file
@@ -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<ActorAppearance> = (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);
|
||||
}
|
||||
@@ -243,6 +243,7 @@ impl StatsRepository for FakeStatsRepository {
|
||||
top_genres: vec![],
|
||||
rating_distribution: [0; 5],
|
||||
watch_medium_distribution: vec![],
|
||||
top_actors: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user