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 errors::{DomainError, DomainResult};
|
||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use models::*;
|
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::*;
|
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(
|
pub fn new_manual(
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
start_time: NaiveTime,
|
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::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 {
|
pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String {
|
||||||
let mut out = String::new();
|
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)]
|
#[cfg(test)]
|
||||||
#[path = "tests/ical.rs"]
|
#[path = "tests/ical.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ pub mod ical;
|
|||||||
pub mod iptv;
|
pub mod iptv;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
|
|
||||||
pub use ical::generate_ical;
|
pub use ical::{generate_ical, parse_ical};
|
||||||
pub use iptv::{generate_m3u, generate_xmltv};
|
pub use iptv::{generate_m3u, generate_xmltv};
|
||||||
pub use schedule::ScheduleEngineService;
|
pub use schedule::ScheduleEngineService;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use crate::models::{ProgrammingBlock, ScheduleConfig};
|
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
|
||||||
use crate::value_objects::{FillStrategy, MediaFilter, Weekday};
|
use crate::value_objects::{FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
|
||||||
use chrono::NaiveTime;
|
use chrono::NaiveTime;
|
||||||
use std::collections::HashMap;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use schemars::JsonSchema;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::tools::{channels, library, schedule};
|
use crate::tools::{channels, ical, library, schedule};
|
||||||
|
|
||||||
const SERVER_NAME: &str = "k-tv-mcp";
|
const SERVER_NAME: &str = "k-tv-mcp";
|
||||||
|
|
||||||
@@ -151,6 +151,12 @@ pub struct SetGapFillerParams {
|
|||||||
pub filter_json: Option<String>,
|
pub filter_json: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct ImportIcalParams {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub ical_string: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_uuid(s: &str) -> Result<Uuid, String> {
|
fn parse_uuid(s: &str) -> Result<Uuid, String> {
|
||||||
s.parse::<Uuid>()
|
s.parse::<Uuid>()
|
||||||
.map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string())
|
.map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string())
|
||||||
@@ -423,6 +429,29 @@ impl KTvMcpServer {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Export a channel's ScheduleConfig as an iCalendar (.ics) string per RFC 5545."
|
||||||
|
)]
|
||||||
|
async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String {
|
||||||
|
match parse_uuid(&p.channel_id) {
|
||||||
|
Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await,
|
||||||
|
Err(e) => e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Import an iCalendar (.ics) string to replace a channel's ScheduleConfig. VEVENTs map to ProgrammingBlocks."
|
||||||
|
)]
|
||||||
|
async fn import_schedule_ical(&self, #[tool(aggr)] p: ImportIcalParams) -> String {
|
||||||
|
match parse_uuid(&p.channel_id) {
|
||||||
|
Ok(id) => {
|
||||||
|
ical::import_schedule_ical(&self.channel_cmd_deps, id, self.owner_id, &p.ical_string)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(e) => e,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(tool_box)]
|
#[tool(tool_box)]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::channels::{ChannelCommandDeps, UpdateChannelCommand};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::domain_err;
|
use crate::error::{domain_err, ok_json};
|
||||||
|
|
||||||
pub async fn export_schedule_ical(
|
pub async fn export_schedule_ical(
|
||||||
channel_query: &Arc<dyn domain::ports::ChannelQuery>,
|
channel_query: &Arc<dyn domain::ports::ChannelQuery>,
|
||||||
@@ -18,3 +19,30 @@ pub async fn export_schedule_ical(
|
|||||||
Err(e) => domain_err(e),
|
Err(e) => domain_err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn import_schedule_ical(
|
||||||
|
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
owner_id: Uuid,
|
||||||
|
ical_str: &str,
|
||||||
|
) -> String {
|
||||||
|
let config = match domain::parse_ical(ical_str) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return domain_err(e),
|
||||||
|
};
|
||||||
|
let cmd = UpdateChannelCommand {
|
||||||
|
channel_id: channel_id.into(),
|
||||||
|
owner_id: owner_id.into(),
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: Some(config),
|
||||||
|
rotation_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
|
};
|
||||||
|
match application::channels::update::execute(cmd_deps, cmd).await {
|
||||||
|
Ok(channel) => ok_json(&channel),
|
||||||
|
Err(e) => domain_err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,3 +170,57 @@ pub async fn list_schedule_history(
|
|||||||
.collect(),
|
.collect(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn export_ical(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let channel_id = ChannelId::from(id);
|
||||||
|
match state.channel_query.find_by_id(channel_id).await {
|
||||||
|
Ok(Some(channel)) => {
|
||||||
|
let ical = domain::generate_ical(
|
||||||
|
channel.name(),
|
||||||
|
channel.timezone(),
|
||||||
|
channel.schedule_config(),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "text/calendar; charset=utf-8"),
|
||||||
|
(
|
||||||
|
"content-disposition",
|
||||||
|
&format!("attachment; filename=\"{}.ics\"", channel.name()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ical,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(e) => AppError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_ical(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Json<api_types::ChannelResponse>, AppError> {
|
||||||
|
let config = domain::parse_ical(&body)?;
|
||||||
|
let cmd = application::channels::UpdateChannelCommand {
|
||||||
|
channel_id: id.into(),
|
||||||
|
owner_id: user.id(),
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
timezone: None,
|
||||||
|
schedule_config: Some(config),
|
||||||
|
rotation_policy: None,
|
||||||
|
auto_schedule: None,
|
||||||
|
gap_filler: None,
|
||||||
|
};
|
||||||
|
let channel =
|
||||||
|
application::channels::update::execute(&state.channel_command_deps, cmd).await?;
|
||||||
|
Ok(Json(api_types::ChannelResponse::from(channel)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ fn channel_router() -> Router<AppState> {
|
|||||||
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
|
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
|
||||||
.route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot))
|
.route("/{id}/snapshots/{snapshot_id}", axum::routing::patch(handlers::channels::patch_snapshot))
|
||||||
.route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot))
|
.route("/{id}/snapshots/{snapshot_id}/restore", post(handlers::channels::restore_snapshot))
|
||||||
|
.route("/{id}/export.ics", get(handlers::schedule::export_ical))
|
||||||
|
.route("/{id}/import", post(handlers::schedule::import_ical))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_router() -> Router<AppState> {
|
fn admin_router() -> Router<AppState> {
|
||||||
|
|||||||
Reference in New Issue
Block a user