add domain concepts: FillStrategy variants, Chapter, InterstitialRule, MidRollRule, gap_filler
- FillStrategy: +Alternating (round-robin series), +Weighted (fresh-first), +Marathon (always ep1, loops) - Chapter value object on MediaItem (JSON column in library_items) - InterstitialRule + MidRollRule on ProgrammingBlock (data model only) - gap_filler: Option<MediaFilter> on Channel (persisted as JSON column) - migration: 20260712000002_add_chapters_and_gap_filler.sql
This commit is contained in:
@@ -36,5 +36,6 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use adapter_common::{
|
||||
use domain::{
|
||||
ports::channel::{ChannelCommand, ChannelQuery},
|
||||
AccessMode, Channel, ChannelConfigSnapshot, ChannelId, ChannelRow as DomainChannelRow,
|
||||
DomainError, DomainResult, LogoPosition, ScheduleConfig, SnapshotId, UserId,
|
||||
DomainError, DomainResult, LogoPosition, MediaFilter, ScheduleConfig, SnapshotId, UserId,
|
||||
};
|
||||
|
||||
pub struct SqliteChannelRepository {
|
||||
@@ -23,7 +23,7 @@ impl SqliteChannelRepository {
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, created_at, updated_at";
|
||||
const SELECT_COLS: &str = "id, owner_id, name, description, timezone, schedule_config, recycle_policy AS rotation_policy, auto_schedule, access_mode, logo, logo_position, logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template, webhook_headers, gap_filler, created_at, updated_at";
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ChannelRow {
|
||||
@@ -43,6 +43,7 @@ struct ChannelRow {
|
||||
webhook_poll_interval_secs: i64,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
gap_filler: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -56,6 +57,11 @@ impl ChannelRow {
|
||||
let access_mode: AccessMode = parse_enum_or_default(self.access_mode);
|
||||
let logo_position: LogoPosition = parse_enum_or_default(self.logo_position);
|
||||
|
||||
let gap_filler: Option<MediaFilter> = self
|
||||
.gap_filler
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
|
||||
Ok(Channel::from_persistence(DomainChannelRow {
|
||||
id,
|
||||
owner_id,
|
||||
@@ -73,6 +79,7 @@ impl ChannelRow {
|
||||
webhook_poll_interval_secs: self.webhook_poll_interval_secs as u32,
|
||||
webhook_body_template: self.webhook_body_template,
|
||||
webhook_headers: self.webhook_headers,
|
||||
gap_filler,
|
||||
created_at: parse_dt(&self.created_at)?,
|
||||
updated_at: parse_dt(&self.updated_at)?,
|
||||
}))
|
||||
@@ -112,14 +119,18 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
let access_mode = serialize_enum_as_string(channel.access_mode(), "public");
|
||||
let logo_position = serialize_enum_as_string(channel.logo_position(), "top_right");
|
||||
|
||||
let gap_filler_json = channel
|
||||
.gap_filler()
|
||||
.map(|f| serde_json::to_string(f).unwrap_or_default());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channels
|
||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy,
|
||||
auto_schedule, access_mode, logo, logo_position,
|
||||
logo_opacity, webhook_url, webhook_poll_interval_secs, webhook_body_template,
|
||||
webhook_headers, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
webhook_headers, gap_filler, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
@@ -135,6 +146,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
webhook_poll_interval_secs = excluded.webhook_poll_interval_secs,
|
||||
webhook_body_template = excluded.webhook_body_template,
|
||||
webhook_headers = excluded.webhook_headers,
|
||||
gap_filler = excluded.gap_filler,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
@@ -154,6 +166,7 @@ impl ChannelCommand for SqliteChannelRepository {
|
||||
.bind(channel.webhook_poll_interval_secs() as i64)
|
||||
.bind(channel.webhook_body_template())
|
||||
.bind(channel.webhook_headers())
|
||||
.bind(&gap_filler_json)
|
||||
.bind(channel.created_at().to_rfc3339())
|
||||
.bind(channel.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -39,6 +39,7 @@ struct LibraryItemRow {
|
||||
collection_type: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
chapters: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryItemRow {
|
||||
@@ -63,6 +64,11 @@ impl LibraryItemRow {
|
||||
thumbnail_url: self.thumbnail_url,
|
||||
synced_at: Some(self.synced_at),
|
||||
role: MediaRole::default(),
|
||||
chapters: self
|
||||
.chapters
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,12 +110,18 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
for item in items {
|
||||
let chapters_json = if item.chapters().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(item.chapters()).unwrap_or_default())
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO library_items
|
||||
(id, provider_id, external_id, title, content_type, duration_secs,
|
||||
series_name, season_number, episode_number, year, genres, tags,
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
collection_id, collection_name, collection_type, thumbnail_url, synced_at, chapters)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(item.id().value())
|
||||
.bind(item.provider_id())
|
||||
@@ -128,6 +140,7 @@ impl LibraryCommand for SqliteLibraryRepository {
|
||||
.bind(item.collection_type())
|
||||
.bind(item.thumbnail_url())
|
||||
.bind(item.synced_at().unwrap_or(""))
|
||||
.bind(&chapters_json)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
@@ -42,6 +42,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
thumbnail_url: None,
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
let scifi = MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new("test::m2"),
|
||||
@@ -63,6 +64,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
thumbnail_url: None,
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
let comedy = MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new("test::m3"),
|
||||
@@ -84,6 +86,7 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
|
||||
thumbnail_url: None,
|
||||
synced_at: Some("2026-01-01".into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
});
|
||||
|
||||
store.insert(action.id().value().to_string(), action);
|
||||
|
||||
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::value_objects::{
|
||||
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId,
|
||||
RotationPolicy, UserId, Weekday,
|
||||
AccessMode, BlockId, ChannelId, FillStrategy, InterstitialRule, LogoPosition, MediaFilter,
|
||||
MediaItemId, MidRollRule, RotationPolicy, UserId, Weekday,
|
||||
};
|
||||
|
||||
const SECONDS_IN_DAY: u32 = 86_400;
|
||||
@@ -29,6 +29,8 @@ pub struct Channel {
|
||||
webhook_poll_interval_secs: u32,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
#[serde(default)]
|
||||
gap_filler: Option<MediaFilter>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -50,6 +52,7 @@ pub struct ChannelRow {
|
||||
pub webhook_poll_interval_secs: u32,
|
||||
pub webhook_body_template: Option<String>,
|
||||
pub webhook_headers: Option<String>,
|
||||
pub gap_filler: Option<MediaFilter>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -78,6 +81,7 @@ impl Channel {
|
||||
webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS,
|
||||
webhook_body_template: None,
|
||||
webhook_headers: None,
|
||||
gap_filler: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -101,6 +105,7 @@ impl Channel {
|
||||
webhook_poll_interval_secs: row.webhook_poll_interval_secs,
|
||||
webhook_body_template: row.webhook_body_template,
|
||||
webhook_headers: row.webhook_headers,
|
||||
gap_filler: row.gap_filler,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
@@ -207,6 +212,15 @@ impl Channel {
|
||||
self.auto_schedule = enabled;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn gap_filler(&self) -> Option<&MediaFilter> {
|
||||
self.gap_filler.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_gap_filler(&mut self, gap_filler: Option<MediaFilter>) {
|
||||
self.gap_filler = gap_filler;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads
|
||||
@@ -324,6 +338,12 @@ pub struct ProgrammingBlock {
|
||||
|
||||
#[serde(default)]
|
||||
ignore_rotation_policy: bool,
|
||||
|
||||
#[serde(default)]
|
||||
interstitial_rule: Option<InterstitialRule>,
|
||||
|
||||
#[serde(default)]
|
||||
mid_roll_rule: Option<MidRollRule>,
|
||||
}
|
||||
|
||||
impl ProgrammingBlock {
|
||||
@@ -345,6 +365,8 @@ impl ProgrammingBlock {
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_rotation_policy: false,
|
||||
interstitial_rule: None,
|
||||
mid_roll_rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +386,8 @@ impl ProgrammingBlock {
|
||||
},
|
||||
loop_on_finish: true,
|
||||
ignore_rotation_policy: false,
|
||||
interstitial_rule: None,
|
||||
mid_roll_rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +418,14 @@ impl ProgrammingBlock {
|
||||
pub fn ignore_rotation_policy(&self) -> bool {
|
||||
self.ignore_rotation_policy
|
||||
}
|
||||
|
||||
pub fn interstitial_rule(&self) -> Option<&InterstitialRule> {
|
||||
self.interstitial_rule.as_ref()
|
||||
}
|
||||
|
||||
pub fn mid_roll_rule(&self) -> Option<&MidRollRule> {
|
||||
self.mid_roll_rule.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::value_objects::{ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
|
||||
use crate::value_objects::{Chapter, ChannelId, ContentType, MediaItemId, MediaRole, PlaybackRecordId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
@@ -29,6 +29,8 @@ pub struct MediaItem {
|
||||
synced_at: Option<String>,
|
||||
#[serde(default)]
|
||||
role: MediaRole,
|
||||
#[serde(default)]
|
||||
chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
pub struct MediaItemRow {
|
||||
@@ -51,6 +53,7 @@ pub struct MediaItemRow {
|
||||
pub collection_type: Option<String>,
|
||||
pub synced_at: Option<String>,
|
||||
pub role: MediaRole,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
@@ -80,6 +83,7 @@ impl MediaItem {
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +118,7 @@ impl MediaItem {
|
||||
collection_type: None,
|
||||
synced_at: Some(synced_at.into()),
|
||||
role: MediaRole::default(),
|
||||
chapters: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +143,7 @@ impl MediaItem {
|
||||
collection_type: row.collection_type,
|
||||
synced_at: row.synced_at,
|
||||
role: row.role,
|
||||
chapters: row.chapters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +222,10 @@ impl MediaItem {
|
||||
pub fn role(&self) -> &MediaRole {
|
||||
&self.role
|
||||
}
|
||||
|
||||
pub fn chapters(&self) -> &[Chapter] {
|
||||
&self.chapters
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::value_objects::PlaybackRecordId;
|
||||
use crate::value_objects::{Chapter, PlaybackRecordId};
|
||||
|
||||
#[test]
|
||||
fn media_item_new_defaults() {
|
||||
@@ -19,6 +19,7 @@ fn media_item_new_defaults() {
|
||||
assert_eq!(item.external_id(), "");
|
||||
assert!(item.synced_at().is_none());
|
||||
assert_eq!(item.role(), &MediaRole::Program);
|
||||
assert!(item.chapters().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -65,6 +66,7 @@ fn media_item_from_persistence_round_trip() {
|
||||
collection_type: Some("tvshows".into()),
|
||||
synced_at: Some("2026-03-19T00:00:00Z".into()),
|
||||
role: MediaRole::Program,
|
||||
chapters: vec![Chapter::new(Some("Intro".into()), 0.0, 30.0)],
|
||||
});
|
||||
assert_eq!(item.title(), "Breaking Bad S01E01");
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
|
||||
@@ -34,6 +34,15 @@ pub(super) fn fill_block<'a>(
|
||||
}
|
||||
result
|
||||
}
|
||||
FillStrategy::Alternating => {
|
||||
fill_alternating(candidates, pool, target_secs)
|
||||
}
|
||||
FillStrategy::Weighted => {
|
||||
fill_weighted(candidates, pool, target_secs)
|
||||
}
|
||||
FillStrategy::Marathon => {
|
||||
fill_marathon(candidates, pool, target_secs, loop_on_finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +127,199 @@ pub(super) fn fill_sequential<'a>(
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn fill_alternating<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
|
||||
let mut groups: Vec<Vec<&MediaItem>> = Vec::new();
|
||||
let mut seen_series: HashSet<Option<&str>> = HashSet::new();
|
||||
|
||||
for item in candidates {
|
||||
if !available.contains(item.id()) {
|
||||
continue;
|
||||
}
|
||||
let key = item.series_name();
|
||||
if !seen_series.contains(&key) {
|
||||
seen_series.insert(key);
|
||||
groups.push(Vec::new());
|
||||
}
|
||||
let group_idx = groups.len() - 1;
|
||||
// Find the group for this series
|
||||
let idx = groups
|
||||
.iter()
|
||||
.position(|g| {
|
||||
g.first()
|
||||
.map(|f| f.series_name() == item.series_name())
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(group_idx);
|
||||
groups[idx].push(item);
|
||||
}
|
||||
|
||||
if groups.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut cursors: Vec<usize> = vec![0; groups.len()];
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
let mut stale_count = 0;
|
||||
|
||||
loop {
|
||||
if stale_count >= groups.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (gi, group) in groups.iter().enumerate() {
|
||||
if remaining == 0 {
|
||||
return result;
|
||||
}
|
||||
if cursors[gi] >= group.len() {
|
||||
stale_count += 1;
|
||||
continue;
|
||||
}
|
||||
let item = group[cursors[gi]];
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(item);
|
||||
cursors[gi] += 1;
|
||||
stale_count = 0;
|
||||
} else {
|
||||
cursors[gi] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn fill_weighted<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
) -> 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
|
||||
.iter()
|
||||
.filter(|i| !candidate_ids.contains(i.id()) || pool_ids.contains(i.id()))
|
||||
.collect();
|
||||
|
||||
let all_in_pool: Vec<&MediaItem> = pool.iter().collect();
|
||||
|
||||
let mut rng = StdRng::from_entropy();
|
||||
fresh.shuffle(&mut rng);
|
||||
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
let mut used: HashSet<&MediaItemId> = HashSet::new();
|
||||
|
||||
for item in &fresh {
|
||||
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
|
||||
}
|
||||
|
||||
pub(super) fn fill_marathon<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
loop_on_finish: bool,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
|
||||
let ordered: Vec<&MediaItem> = candidates
|
||||
.iter()
|
||||
.filter(|item| available.contains(item.id()))
|
||||
.collect();
|
||||
|
||||
if ordered.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
|
||||
if loop_on_finish {
|
||||
let mut idx = 0;
|
||||
while remaining > 0 {
|
||||
let item = ordered[idx % ordered.len()];
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(item);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
idx += 1;
|
||||
if idx > ordered.len() * 100 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for item in &ordered {
|
||||
if item.duration_secs() <= remaining {
|
||||
remaining -= item.duration_secs();
|
||||
result.push(*item);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
if let Some(&first) = ordered.first() {
|
||||
result.push(first);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/fill.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -68,3 +68,148 @@ fn random_fill_respects_budget() {
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
fn episode(id: &str, series: &str, secs: u32) -> MediaItem {
|
||||
let mut i = item(id, secs);
|
||||
// Use serde to set series_name since fields are private
|
||||
let mut val = serde_json::to_value(&i).unwrap();
|
||||
val["series_name"] = serde_json::Value::String(series.into());
|
||||
serde_json::from_value(val).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_cycles_through_series() {
|
||||
let candidates = vec![
|
||||
episode("s1e1", "Show A", 60),
|
||||
episode("s1e2", "Show A", 60),
|
||||
episode("s2e1", "Show B", 60),
|
||||
episode("s2e2", "Show B", 60),
|
||||
];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_alternating(&candidates, &pool, 240);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0].series_name(), Some("Show A"));
|
||||
assert_eq!(result[1].series_name(), Some("Show B"));
|
||||
assert_eq!(result[2].series_name(), Some("Show A"));
|
||||
assert_eq!(result[3].series_name(), Some("Show B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_respects_budget() {
|
||||
let candidates = vec![
|
||||
episode("s1e1", "Show A", 100),
|
||||
episode("s2e1", "Show B", 100),
|
||||
];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_alternating(&candidates, &pool, 150);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_alternating(&candidates, &pool, 300);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_weighted(&candidates, &pool, 300);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_starts_from_beginning() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 180, false);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].id().value(), "ep1");
|
||||
assert_eq!(result[1].id().value(), "ep2");
|
||||
assert_eq!(result[2].id().value(), "ep3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_loops_when_enabled() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 240, true);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0].id().value(), "ep1");
|
||||
assert_eq!(result[1].id().value(), "ep2");
|
||||
assert_eq!(result[2].id().value(), "ep1");
|
||||
assert_eq!(result[3].id().value(), "ep2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_stops_without_loop() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 300, false);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_empty_pool() {
|
||||
let candidates: Vec<MediaItem> = vec![];
|
||||
let pool: Vec<MediaItem> = vec![];
|
||||
let result = fill_marathon(&candidates, &pool, 300, true);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marathon_oversize_single_item() {
|
||||
let candidates = vec![item("ep1", 9999)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_marathon(&candidates, &pool, 60, true);
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert_eq!(result[0].id().value(), "a");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ pub enum FillStrategy {
|
||||
BestFit,
|
||||
Sequential,
|
||||
Random,
|
||||
Alternating,
|
||||
Weighted,
|
||||
Marathon,
|
||||
}
|
||||
|
||||
const DEFAULT_COOLDOWN_DAYS: u32 = 30;
|
||||
@@ -99,6 +102,75 @@ pub enum MediaRole {
|
||||
Interstitial,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterstitialRule {
|
||||
pool_filter: MediaFilter,
|
||||
strategy: FillStrategy,
|
||||
min_gap_secs: u32,
|
||||
}
|
||||
|
||||
impl InterstitialRule {
|
||||
pub fn new(pool_filter: MediaFilter, strategy: FillStrategy, min_gap_secs: u32) -> Self {
|
||||
Self {
|
||||
pool_filter,
|
||||
strategy,
|
||||
min_gap_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pool_filter(&self) -> &MediaFilter {
|
||||
&self.pool_filter
|
||||
}
|
||||
|
||||
pub fn strategy(&self) -> &FillStrategy {
|
||||
&self.strategy
|
||||
}
|
||||
|
||||
pub fn min_gap_secs(&self) -> u32 {
|
||||
self.min_gap_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MidRollRule {
|
||||
prefer_chapters: bool,
|
||||
fallback_interval_mins: u32,
|
||||
break_duration_secs: u32,
|
||||
pool_filter: MediaFilter,
|
||||
}
|
||||
|
||||
impl MidRollRule {
|
||||
pub fn new(
|
||||
prefer_chapters: bool,
|
||||
fallback_interval_mins: u32,
|
||||
break_duration_secs: u32,
|
||||
pool_filter: MediaFilter,
|
||||
) -> Self {
|
||||
Self {
|
||||
prefer_chapters,
|
||||
fallback_interval_mins,
|
||||
break_duration_secs,
|
||||
pool_filter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefer_chapters(&self) -> bool {
|
||||
self.prefer_chapters
|
||||
}
|
||||
|
||||
pub fn fallback_interval_mins(&self) -> u32 {
|
||||
self.fallback_interval_mins
|
||||
}
|
||||
|
||||
pub fn break_duration_secs(&self) -> u32 {
|
||||
self.break_duration_secs
|
||||
}
|
||||
|
||||
pub fn pool_filter(&self) -> &MediaFilter {
|
||||
&self.pool_filter
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/scheduling.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -6,3 +6,40 @@ pub enum SourceUri {
|
||||
NetworkUrl { url: String },
|
||||
FilePath { path: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Chapter {
|
||||
title: Option<String>,
|
||||
start_secs: f64,
|
||||
end_secs: f64,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
pub fn new(title: Option<String>, start_secs: f64, end_secs: f64) -> Self {
|
||||
Self {
|
||||
title,
|
||||
start_secs,
|
||||
end_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(title: Option<String>, start_secs: f64, end_secs: f64) -> Self {
|
||||
Self {
|
||||
title,
|
||||
start_secs,
|
||||
end_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.title.as_deref()
|
||||
}
|
||||
|
||||
pub fn start_secs(&self) -> f64 {
|
||||
self.start_secs
|
||||
}
|
||||
|
||||
pub fn end_secs(&self) -> f64 {
|
||||
self.end_secs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +459,7 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
|
||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||
synced_at: Some(now),
|
||||
role: domain::MediaRole::default(),
|
||||
chapters: item.chapters().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ fn provider_item_to_library_item(item: domain::MediaItem, provider_id: &str) ->
|
||||
thumbnail_url: item.thumbnail_url().map(|s| s.to_string()),
|
||||
synced_at: Some(now),
|
||||
role: domain::MediaRole::default(),
|
||||
chapters: item.chapters().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE library_items ADD COLUMN chapters TEXT;
|
||||
ALTER TABLE channels ADD COLUMN gap_filler TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE library_items ADD COLUMN chapters TEXT;
|
||||
ALTER TABLE channels ADD COLUMN gap_filler TEXT;
|
||||
Reference in New Issue
Block a user