iCalendar import: parse_ical + API + MCP tool (#16)
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("ed)
|
||||
.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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user