fix: fill_weighted sorts by recency via playback history
This commit is contained in:
@@ -4,7 +4,9 @@ use rand::rngs::StdRng;
|
|||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
|
|
||||||
use crate::models::MediaItem;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::models::{MediaItem, PlaybackRecord};
|
||||||
use crate::value_objects::{FillStrategy, MediaItemId};
|
use crate::value_objects::{FillStrategy, MediaItemId};
|
||||||
|
|
||||||
pub(super) fn fill_block<'a>(
|
pub(super) fn fill_block<'a>(
|
||||||
@@ -14,6 +16,7 @@ pub(super) fn fill_block<'a>(
|
|||||||
strategy: &FillStrategy,
|
strategy: &FillStrategy,
|
||||||
last_item_id: Option<&MediaItemId>,
|
last_item_id: Option<&MediaItemId>,
|
||||||
loop_on_finish: bool,
|
loop_on_finish: bool,
|
||||||
|
history: &[PlaybackRecord],
|
||||||
) -> Vec<&'a MediaItem> {
|
) -> Vec<&'a MediaItem> {
|
||||||
match strategy {
|
match strategy {
|
||||||
FillStrategy::BestFit => fill_best_fit(pool, target_secs),
|
FillStrategy::BestFit => fill_best_fit(pool, target_secs),
|
||||||
@@ -38,7 +41,7 @@ pub(super) fn fill_block<'a>(
|
|||||||
fill_alternating(candidates, pool, target_secs)
|
fill_alternating(candidates, pool, target_secs)
|
||||||
}
|
}
|
||||||
FillStrategy::Weighted => {
|
FillStrategy::Weighted => {
|
||||||
fill_weighted(candidates, pool, target_secs)
|
fill_weighted(pool, target_secs, history)
|
||||||
}
|
}
|
||||||
FillStrategy::Marathon => {
|
FillStrategy::Marathon => {
|
||||||
fill_marathon(candidates, pool, target_secs, loop_on_finish)
|
fill_marathon(candidates, pool, target_secs, loop_on_finish)
|
||||||
@@ -201,60 +204,39 @@ pub(super) fn fill_alternating<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn fill_weighted<'a>(
|
pub(super) fn fill_weighted<'a>(
|
||||||
candidates: &'a [MediaItem],
|
|
||||||
pool: &'a [MediaItem],
|
pool: &'a [MediaItem],
|
||||||
target_secs: u32,
|
target_secs: u32,
|
||||||
|
history: &[PlaybackRecord],
|
||||||
) -> Vec<&'a MediaItem> {
|
) -> Vec<&'a MediaItem> {
|
||||||
if pool.is_empty() {
|
if pool.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
let last_played: HashMap<&MediaItemId, i64> = history
|
||||||
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
|
|
||||||
|
|
||||||
let mut fresh: Vec<&MediaItem> = pool
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
|
.fold(HashMap::new(), |mut acc, r| {
|
||||||
.collect();
|
let ts = r.played_at().timestamp();
|
||||||
|
acc.entry(r.item_id())
|
||||||
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
|
.and_modify(|prev| *prev = (*prev).max(ts))
|
||||||
|
.or_insert(ts);
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut items: Vec<&MediaItem> = pool.iter().collect();
|
||||||
let mut rng = StdRng::from_entropy();
|
let mut rng = StdRng::from_entropy();
|
||||||
fresh.shuffle(&mut rng);
|
items.shuffle(&mut rng);
|
||||||
|
items.sort_by_key(|i| last_played.get(i.id()).copied().unwrap_or(i64::MIN));
|
||||||
|
|
||||||
let mut remaining = target_secs;
|
let mut remaining = target_secs;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut used: HashSet<&MediaItemId> = HashSet::new();
|
|
||||||
|
|
||||||
for item in &fresh {
|
for item in items {
|
||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if used.contains(item.id()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if item.duration_secs() <= remaining {
|
if item.duration_secs() <= remaining {
|
||||||
remaining -= item.duration_secs();
|
remaining -= item.duration_secs();
|
||||||
used.insert(item.id());
|
result.push(item);
|
||||||
result.push(*item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remaining > 0 {
|
|
||||||
let mut rest: Vec<&MediaItem> = all_in_pool
|
|
||||||
.iter()
|
|
||||||
.filter(|i| !used.contains(i.id()))
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
rest.shuffle(&mut rng);
|
|
||||||
for item in rest {
|
|
||||||
if remaining == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if item.duration_secs() <= remaining {
|
|
||||||
remaining -= item.duration_secs();
|
|
||||||
result.push(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ impl ScheduleEngineService {
|
|||||||
params.strategy,
|
params.strategy,
|
||||||
rotation.last_item_id,
|
rotation.last_item_id,
|
||||||
params.loop_on_finish,
|
params.loop_on_finish,
|
||||||
|
rotation.history,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut slots = Vec::new();
|
let mut slots = Vec::new();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ fn sequential_includes_oversize_first_episode() {
|
|||||||
fn random_fill_respects_budget() {
|
fn random_fill_respects_budget() {
|
||||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||||
let candidates = pool.clone();
|
let candidates = pool.clone();
|
||||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true);
|
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true, &[]);
|
||||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||||
assert!(total <= 200);
|
assert!(total <= 200);
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ fn alternating_empty_pool() {
|
|||||||
fn weighted_respects_budget() {
|
fn weighted_respects_budget() {
|
||||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||||
let candidates = pool.clone();
|
let candidates = pool.clone();
|
||||||
let result = fill_weighted(&candidates, &pool, 200);
|
let result = fill_weighted(&pool, 200, &[]);
|
||||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||||
assert!(total <= 200);
|
assert!(total <= 200);
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ fn weighted_respects_budget() {
|
|||||||
fn weighted_no_duplicates() {
|
fn weighted_no_duplicates() {
|
||||||
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
|
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
|
||||||
let candidates = pool.clone();
|
let candidates = pool.clone();
|
||||||
let result = fill_weighted(&candidates, &pool, 150);
|
let result = fill_weighted(&pool, 150, &[]);
|
||||||
let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect();
|
let ids: Vec<&str> = result.iter().map(|i| i.id().value()).collect();
|
||||||
let unique: HashSet<&str> = ids.iter().copied().collect();
|
let unique: HashSet<&str> = ids.iter().copied().collect();
|
||||||
assert_eq!(ids.len(), unique.len());
|
assert_eq!(ids.len(), unique.len());
|
||||||
@@ -137,7 +137,7 @@ fn weighted_no_duplicates() {
|
|||||||
fn weighted_empty_pool() {
|
fn weighted_empty_pool() {
|
||||||
let candidates: Vec<MediaItem> = vec![];
|
let candidates: Vec<MediaItem> = vec![];
|
||||||
let pool: Vec<MediaItem> = vec![];
|
let pool: Vec<MediaItem> = vec![];
|
||||||
let result = fill_weighted(&candidates, &pool, 300);
|
let result = fill_weighted(&pool, 300, &[]);
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ fn marathon_oversize_single_item() {
|
|||||||
fn fill_block_dispatches_alternating() {
|
fn fill_block_dispatches_alternating() {
|
||||||
let candidates = vec![item("a", 100), item("b", 100)];
|
let candidates = vec![item("a", 100), item("b", 100)];
|
||||||
let pool = candidates.clone();
|
let pool = candidates.clone();
|
||||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true);
|
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Alternating, None, true, &[]);
|
||||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||||
assert!(total <= 200);
|
assert!(total <= 200);
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ fn fill_block_dispatches_alternating() {
|
|||||||
fn fill_block_dispatches_weighted() {
|
fn fill_block_dispatches_weighted() {
|
||||||
let candidates = vec![item("a", 100), item("b", 100)];
|
let candidates = vec![item("a", 100), item("b", 100)];
|
||||||
let pool = candidates.clone();
|
let pool = candidates.clone();
|
||||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true);
|
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Weighted, None, true, &[]);
|
||||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||||
assert!(total <= 200);
|
assert!(total <= 200);
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,6 @@ fn fill_block_dispatches_weighted() {
|
|||||||
fn fill_block_dispatches_marathon() {
|
fn fill_block_dispatches_marathon() {
|
||||||
let candidates = vec![item("a", 100), item("b", 100)];
|
let candidates = vec![item("a", 100), item("b", 100)];
|
||||||
let pool = candidates.clone();
|
let pool = candidates.clone();
|
||||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true);
|
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Marathon, None, true, &[]);
|
||||||
assert_eq!(result[0].id().value(), "a");
|
assert_eq!(result[0].id().value(), "a");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user