iCalendar import: parse_ical + API + MCP tool (#16)

This commit is contained in:
2026-07-12 14:46:15 +02:00
parent 6b0d060945
commit a7fa2ec4aa
9 changed files with 565 additions and 7 deletions

View File

@@ -10,5 +10,5 @@ pub mod value_objects;
pub use errors::{DomainError, DomainResult};
pub use events::DomainEvent;
pub use models::*;
pub use services::{generate_ical, generate_m3u, generate_xmltv, ScheduleEngineService};
pub use services::{generate_ical, generate_m3u, generate_xmltv, parse_ical, ScheduleEngineService};
pub use value_objects::*;

View File

@@ -377,6 +377,28 @@ impl ProgrammingBlock {
}
}
pub fn from_parts(
id: BlockId,
name: impl Into<String>,
start_time: NaiveTime,
duration_mins: u32,
content: BlockContent,
interstitial_rule: Option<InterstitialRule>,
mid_roll_rule: Option<MidRollRule>,
) -> Self {
Self {
id,
name: name.into(),
start_time,
duration_mins,
content,
loop_on_finish: true,
ignore_rotation_policy: false,
interstitial_rule,
mid_roll_rule,
}
}
pub fn new_manual(
name: impl Into<String>,
start_time: NaiveTime,

View File

@@ -1,5 +1,10 @@
use std::collections::HashMap;
use chrono::NaiveTime;
use crate::errors::DomainResult;
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
use crate::value_objects::Weekday;
use crate::value_objects::{BlockId, FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String {
let mut out = String::new();
@@ -104,6 +109,233 @@ fn write_folded_property(out: &mut String, name: &str, value: &str) {
}
}
pub fn parse_ical(ical_str: &str) -> DomainResult<ScheduleConfig> {
let unfolded = unfold_lines(ical_str);
let lines: Vec<&str> = unfolded.lines().collect();
if !lines.iter().any(|l| l.starts_with("BEGIN:VCALENDAR")) {
return Err(crate::DomainError::validation("missing BEGIN:VCALENDAR"));
}
if !lines.iter().any(|l| l.starts_with("END:VCALENDAR")) {
return Err(crate::DomainError::validation("missing END:VCALENDAR"));
}
let mut day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>> = HashMap::new();
let mut in_vevent = false;
let mut props: Vec<(&str, &str)> = Vec::new();
for line in &lines {
if *line == "BEGIN:VEVENT" {
in_vevent = true;
props.clear();
continue;
}
if *line == "END:VEVENT" {
if in_vevent {
let block = parse_vevent(&props)?;
let days = extract_byday(&props)?;
for day in days {
day_blocks.entry(day).or_default().push(block.clone());
}
}
in_vevent = false;
continue;
}
if in_vevent && let Some((name, value)) = line.split_once(':') {
props.push((name, value));
}
}
Ok(ScheduleConfig::from_day_blocks(day_blocks))
}
fn unfold_lines(s: &str) -> String {
let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
let mut result = String::with_capacity(normalized.len());
for line in normalized.split('\n') {
if line.starts_with(' ') || line.starts_with('\t') {
result.push_str(&line[1..]);
} else {
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
}
result
}
fn extract_byday(props: &[(&str, &str)]) -> DomainResult<Vec<Weekday>> {
let rrule = props
.iter()
.find(|(n, _)| *n == "RRULE")
.map(|(_, v)| *v)
.unwrap_or("");
let byday_part = rrule
.split(';')
.find(|p| p.starts_with("BYDAY="))
.and_then(|p| p.strip_prefix("BYDAY="));
match byday_part {
Some(days_str) => {
let mut days = Vec::new();
for d in days_str.split(',') {
days.push(byday_to_weekday(d.trim())?);
}
Ok(days)
}
None => Err(crate::DomainError::validation(
"VEVENT missing RRULE with BYDAY",
)),
}
}
fn byday_to_weekday(s: &str) -> DomainResult<Weekday> {
match s {
"MO" => Ok(Weekday::Monday),
"TU" => Ok(Weekday::Tuesday),
"WE" => Ok(Weekday::Wednesday),
"TH" => Ok(Weekday::Thursday),
"FR" => Ok(Weekday::Friday),
"SA" => Ok(Weekday::Saturday),
"SU" => Ok(Weekday::Sunday),
_ => Err(crate::DomainError::validation(format!(
"unknown BYDAY value: {s}"
))),
}
}
fn parse_vevent(props: &[(&str, &str)]) -> DomainResult<ProgrammingBlock> {
let uid = prop_value(props, "UID").unwrap_or("");
let id: BlockId = if uid.is_empty() {
BlockId::generate()
} else {
uid.parse()
.unwrap_or_else(|_| BlockId::generate())
};
let name = prop_value(props, "SUMMARY")
.map(unfold_value)
.unwrap_or_else(|| "Untitled".to_string());
let start_time = parse_dtstart(prop_value(props, "DTSTART").unwrap_or(""))?;
let duration_mins = parse_duration(prop_value(props, "DURATION").unwrap_or(""))?;
let content = match prop_value(props, "X-KTV-CONTENT") {
Some(json) => serde_json::from_str(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-CONTENT: {e}"))
})?,
None => default_content(props),
};
let interstitial_rule = match prop_value(props, "X-KTV-INTERSTITIAL") {
Some(json) => Some(serde_json::from_str::<InterstitialRule>(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-INTERSTITIAL: {e}"))
})?),
None => None,
};
let mid_roll_rule = match prop_value(props, "X-KTV-MIDROLL") {
Some(json) => Some(serde_json::from_str::<MidRollRule>(json).map_err(|e| {
crate::DomainError::validation(format!("invalid X-KTV-MIDROLL: {e}"))
})?),
None => None,
};
Ok(ProgrammingBlock::from_parts(
id,
name,
start_time,
duration_mins,
content,
interstitial_rule,
mid_roll_rule,
))
}
fn default_content(props: &[(&str, &str)]) -> BlockContent {
let strategy = match prop_value(props, "X-KTV-STRATEGY") {
Some(s) => parse_strategy(s).unwrap_or(FillStrategy::Random),
None => FillStrategy::Random,
};
BlockContent::Algorithmic {
filter: MediaFilter::default(),
strategy,
}
}
fn parse_strategy(s: &str) -> DomainResult<FillStrategy> {
let quoted = format!("\"{}\"", s);
serde_json::from_str(&quoted)
.map_err(|e| crate::DomainError::validation(format!("invalid strategy '{s}': {e}")))
}
fn prop_value<'a>(props: &[(&str, &'a str)], name: &str) -> Option<&'a str> {
props.iter().find(|(n, _)| *n == name).map(|(_, v)| *v)
}
fn unfold_value(s: &str) -> String {
s.replace("\\\\", "\x00")
.replace("\\,", ",")
.replace("\\;", ";")
.replace('\x00', "\\")
}
fn parse_dtstart(s: &str) -> DomainResult<NaiveTime> {
if s.len() < 6 {
return Err(crate::DomainError::validation(format!(
"invalid DTSTART: {s}"
)));
}
let time_part = if s.contains('T') {
s.split('T').next_back().unwrap_or(s)
} else {
s
};
let digits = &time_part[..6.min(time_part.len())];
NaiveTime::parse_from_str(digits, "%H%M%S")
.map_err(|e| crate::DomainError::validation(format!("invalid DTSTART time '{s}': {e}")))
}
fn parse_duration(s: &str) -> DomainResult<u32> {
if !s.starts_with("PT") {
return Err(crate::DomainError::validation(format!(
"invalid DURATION: {s}"
)));
}
let body = &s[2..];
let mut total_mins: u32 = 0;
let mut num_buf = String::new();
for c in body.chars() {
if c.is_ascii_digit() {
num_buf.push(c);
} else {
let n: u32 = num_buf.parse().map_err(|_| {
crate::DomainError::validation(format!("invalid DURATION number in: {s}"))
})?;
num_buf.clear();
match c {
'H' => total_mins += n * 60,
'M' => total_mins += n,
'S' => total_mins += n / 60,
_ => {
return Err(crate::DomainError::validation(format!(
"unknown DURATION unit '{c}' in: {s}"
)))
}
}
}
}
if total_mins == 0 {
return Err(crate::DomainError::validation(format!(
"zero DURATION: {s}"
)));
}
Ok(total_mins)
}
#[cfg(test)]
#[path = "tests/ical.rs"]
mod tests;

View File

@@ -2,6 +2,6 @@ pub mod ical;
pub mod iptv;
pub mod schedule;
pub use ical::generate_ical;
pub use ical::{generate_ical, parse_ical};
pub use iptv::{generate_m3u, generate_xmltv};
pub use schedule::ScheduleEngineService;

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::models::{ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{FillStrategy, MediaFilter, Weekday};
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
use chrono::NaiveTime;
use std::collections::HashMap;
@@ -143,3 +143,194 @@ fn all_weekdays_mapped() {
);
}
}
#[test]
fn roundtrip_algorithmic_block() {
let block = ProgrammingBlock::new_algorithmic(
"Morning Cartoons",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
120,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]);
let ical = generate_ical("Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Monday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Morning Cartoons");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(8, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 120);
assert_eq!(b.id(), block.id());
assert!(matches!(b.content(), BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }));
}
#[test]
fn roundtrip_multi_day() {
let mon = ProgrammingBlock::new_algorithmic(
"Mon Block",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Sequential,
);
let fri = ProgrammingBlock::new_algorithmic(
"Fri Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
90,
MediaFilter::default(),
FillStrategy::BestFit,
);
let original = make_config_with_blocks(vec![
(Weekday::Monday, vec![mon]),
(Weekday::Friday, vec![fri]),
]);
let ical = generate_ical("Multi", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Monday)[0].name(), "Mon Block");
assert_eq!(parsed.blocks_for(Weekday::Friday)[0].name(), "Fri Block");
}
#[test]
fn roundtrip_manual_block() {
let block = ProgrammingBlock::new_manual(
"Manual Show",
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
45,
vec![],
);
let original = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]);
let ical = generate_ical("Manual", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Wednesday);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].name(), "Manual Show");
assert_eq!(blocks[0].duration_mins(), 45);
assert!(matches!(blocks[0].content(), BlockContent::Manual { .. }));
}
#[test]
fn roundtrip_with_interstitial_and_midroll() {
let mut block = ProgrammingBlock::new_algorithmic(
"Full Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
180,
MediaFilter::default(),
FillStrategy::Random,
);
let interstitial = InterstitialRule::new(MediaFilter::default(), FillStrategy::Random, 30);
let midroll = MidRollRule::new(true, 15, 60, MediaFilter::default());
block = ProgrammingBlock::from_parts(
block.id(),
block.name().to_string(),
block.start_time(),
block.duration_mins(),
block.content().clone(),
Some(interstitial),
Some(midroll),
);
let original = make_config_with_blocks(vec![(Weekday::Saturday, vec![block])]);
let ical = generate_ical("Full", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Saturday);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].interstitial_rule().is_some());
assert!(blocks[0].mid_roll_rule().is_some());
assert_eq!(blocks[0].interstitial_rule().unwrap().min_gap_secs(), 30);
assert!(blocks[0].mid_roll_rule().unwrap().prefer_chapters());
}
#[test]
fn import_without_ktv_properties_uses_defaults() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Generic Event\r\n\
DTSTART:090000\r\n\
DURATION:PT1H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=TU\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Tuesday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Generic Event");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(9, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 60);
assert!(matches!(
b.content(),
BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }
));
assert!(b.interstitial_rule().is_none());
assert!(b.mid_roll_rule().is_none());
}
#[test]
fn import_invalid_ical_missing_vcalendar() {
let result = parse_ical("not an ical file");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BEGIN:VCALENDAR"), "error: {err}");
}
#[test]
fn import_invalid_ical_bad_duration() {
let ical = "BEGIN:VCALENDAR\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Bad\r\n\
DTSTART:090000\r\n\
DURATION:INVALID\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let result = parse_ical(ical);
assert!(result.is_err());
}
#[test]
fn import_handles_line_folding() {
let long_summary = "A".repeat(100);
let block = ProgrammingBlock::new_algorithmic(
&long_summary,
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Thursday, vec![block])]);
let ical = generate_ical("Fold Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Thursday);
assert_eq!(blocks.len(), 1);
}
#[test]
fn import_multiple_byday() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Weekday Show\r\n\
DTSTART:180000\r\n\
DURATION:PT2H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Wednesday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Tuesday).len(), 0);
}