fix: fill_weighted sorts by recency via playback history

This commit is contained in:
2026-07-12 15:24:17 +02:00
parent 9558f04f73
commit 8dabbdf280
3 changed files with 27 additions and 44 deletions

View File

@@ -4,7 +4,9 @@ use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::SeedableRng;
use crate::models::MediaItem;
use std::collections::HashMap;
use crate::models::{MediaItem, PlaybackRecord};
use crate::value_objects::{FillStrategy, MediaItemId};
pub(super) fn fill_block<'a>(
@@ -14,6 +16,7 @@ pub(super) fn fill_block<'a>(
strategy: &FillStrategy,
last_item_id: Option<&MediaItemId>,
loop_on_finish: bool,
history: &[PlaybackRecord],
) -> Vec<&'a MediaItem> {
match strategy {
FillStrategy::BestFit => fill_best_fit(pool, target_secs),
@@ -38,7 +41,7 @@ pub(super) fn fill_block<'a>(
fill_alternating(candidates, pool, target_secs)
}
FillStrategy::Weighted => {
fill_weighted(candidates, pool, target_secs)
fill_weighted(pool, target_secs, history)
}
FillStrategy::Marathon => {
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>(
candidates: &'a [MediaItem],
pool: &'a [MediaItem],
target_secs: u32,
history: &[PlaybackRecord],
) -> Vec<&'a MediaItem> {
if pool.is_empty() {
return vec![];
}
let pool_ids: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
let candidate_ids: HashSet<&MediaItemId> = candidates.iter().map(|i| i.id()).collect();
let mut fresh: Vec<&MediaItem> = pool
let last_played: HashMap<&MediaItemId, i64> = history
.iter()
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
.collect();
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
.fold(HashMap::new(), |mut acc, r| {
let ts = r.played_at().timestamp();
acc.entry(r.item_id())
.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();
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 result = Vec::new();
let mut used: HashSet<&MediaItemId> = HashSet::new();
for item in &fresh {
for item in items {
if remaining == 0 {
break;
}
if used.contains(item.id()) {
continue;
}
if item.duration_secs() <= remaining {
remaining -= item.duration_secs();
used.insert(item.id());
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);
}
result.push(item);
}
}

View File

@@ -744,6 +744,7 @@ impl ScheduleEngineService {
params.strategy,
rotation.last_item_id,
params.loop_on_finish,
rotation.history,
);
let mut slots = Vec::new();

View File

@@ -64,7 +64,7 @@ fn sequential_includes_oversize_first_episode() {
fn random_fill_respects_budget() {
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
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();
assert!(total <= 200);
}
@@ -118,7 +118,7 @@ fn alternating_empty_pool() {
fn weighted_respects_budget() {
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
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();
assert!(total <= 200);
}
@@ -127,7 +127,7 @@ fn weighted_respects_budget() {
fn weighted_no_duplicates() {
let pool = vec![item("a", 50), item("b", 50), item("c", 50)];
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 unique: HashSet<&str> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len());
@@ -137,7 +137,7 @@ fn weighted_no_duplicates() {
fn weighted_empty_pool() {
let candidates: 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());
}
@@ -192,7 +192,7 @@ fn marathon_oversize_single_item() {
fn fill_block_dispatches_alternating() {
let candidates = vec![item("a", 100), item("b", 100)];
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();
assert!(total <= 200);
}
@@ -201,7 +201,7 @@ fn fill_block_dispatches_alternating() {
fn fill_block_dispatches_weighted() {
let candidates = vec![item("a", 100), item("b", 100)];
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();
assert!(total <= 200);
}
@@ -210,6 +210,6 @@ fn fill_block_dispatches_weighted() {
fn fill_block_dispatches_marathon() {
let candidates = vec![item("a", 100), item("b", 100)];
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");
}