feat: favorite actors top-5 stat in profile trends
All checks were successful
CI / Check / Test (push) Successful in 1h5m14s

This commit is contained in:
2026-08-09 20:59:18 +02:00
parent d282e8ea7e
commit 4d221e60de
13 changed files with 297 additions and 10 deletions

View File

@@ -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,
}

View File

@@ -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,
})
}
}

View File

@@ -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,

View File

@@ -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,
})
}
}

View File

@@ -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)]

View File

@@ -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};

View File

@@ -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;

View 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);
}

View File

@@ -243,6 +243,7 @@ impl StatsRepository for FakeStatsRepository {
top_genres: vec![],
rating_distribution: [0; 5],
watch_medium_distribution: vec![],
top_actors: vec![],
})
}

View File

@@ -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(),
}
}