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:
2026-07-12 07:41:57 +02:00
parent 39f5f99bfd
commit 60524c56f7
16 changed files with 547 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -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()))?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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