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:
@@ -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![],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user