diff --git a/crates/domain/src/services/schedule/fill.rs b/crates/domain/src/services/schedule/fill.rs index f4b71d4..f722470 100644 --- a/crates/domain/src/services/schedule/fill.rs +++ b/crates/domain/src/services/schedule/fill.rs @@ -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); } } diff --git a/crates/domain/src/services/schedule/mod.rs b/crates/domain/src/services/schedule/mod.rs index b27227a..edd8911 100644 --- a/crates/domain/src/services/schedule/mod.rs +++ b/crates/domain/src/services/schedule/mod.rs @@ -744,6 +744,7 @@ impl ScheduleEngineService { params.strategy, rotation.last_item_id, params.loop_on_finish, + rotation.history, ); let mut slots = Vec::new(); diff --git a/crates/domain/src/services/schedule/tests/fill.rs b/crates/domain/src/services/schedule/tests/fill.rs index 9532045..373d466 100644 --- a/crates/domain/src/services/schedule/tests/fill.rs +++ b/crates/domain/src/services/schedule/tests/fill.rs @@ -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 = vec![]; let pool: Vec = 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"); }