iCalendar export: domain service + API + MCP tool (#15)
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_m3u, generate_xmltv, ScheduleEngineService};
|
pub use services::{generate_ical, generate_m3u, generate_xmltv, ScheduleEngineService};
|
||||||
pub use value_objects::*;
|
pub use value_objects::*;
|
||||||
|
|||||||
109
crates/domain/src/services/ical.rs
Normal file
109
crates/domain/src/services/ical.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
|
||||||
|
use crate::value_objects::Weekday;
|
||||||
|
|
||||||
|
pub fn generate_ical(channel_name: &str, timezone: &str, config: &ScheduleConfig) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("BEGIN:VCALENDAR\r\n");
|
||||||
|
out.push_str("VERSION:2.0\r\n");
|
||||||
|
out.push_str("PRODID:-//K-TV//Schedule Export//EN\r\n");
|
||||||
|
out.push_str("CALSCALE:GREGORIAN\r\n");
|
||||||
|
out.push_str(&format!("X-WR-CALNAME:{}\r\n", fold_line(channel_name)));
|
||||||
|
out.push_str(&format!("X-WR-TIMEZONE:{}\r\n", timezone));
|
||||||
|
|
||||||
|
for day in Weekday::all() {
|
||||||
|
for block in config.blocks_for(day) {
|
||||||
|
write_vevent(&mut out, day, block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("END:VCALENDAR\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_vevent(out: &mut String, day: Weekday, block: &ProgrammingBlock) {
|
||||||
|
let byday = weekday_to_byday(day);
|
||||||
|
let hours = block.start_time().format("%H%M%S");
|
||||||
|
let dur = format_duration(block.duration_mins());
|
||||||
|
|
||||||
|
out.push_str("BEGIN:VEVENT\r\n");
|
||||||
|
out.push_str(&format!("UID:{}\r\n", block.id()));
|
||||||
|
out.push_str(&format!("DTSTART:{}\r\n", hours));
|
||||||
|
out.push_str(&format!("DURATION:{}\r\n", dur));
|
||||||
|
out.push_str(&format!("RRULE:FREQ=WEEKLY;BYDAY={}\r\n", byday));
|
||||||
|
out.push_str(&format!("SUMMARY:{}\r\n", fold_line(block.name())));
|
||||||
|
|
||||||
|
let content_json = serde_json::to_string(block.content()).unwrap_or_default();
|
||||||
|
write_folded_property(out, "X-KTV-CONTENT", &content_json);
|
||||||
|
|
||||||
|
if let BlockContent::Algorithmic { strategy, .. } = block.content() {
|
||||||
|
let strategy_name = serde_json::to_string(strategy)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
out.push_str(&format!("X-KTV-STRATEGY:{}\r\n", strategy_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rule) = block.interstitial_rule() {
|
||||||
|
let json = serde_json::to_string(rule).unwrap_or_default();
|
||||||
|
write_folded_property(out, "X-KTV-INTERSTITIAL", &json);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rule) = block.mid_roll_rule() {
|
||||||
|
let json = serde_json::to_string(rule).unwrap_or_default();
|
||||||
|
write_folded_property(out, "X-KTV-MIDROLL", &json);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("END:VEVENT\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weekday_to_byday(day: Weekday) -> &'static str {
|
||||||
|
match day {
|
||||||
|
Weekday::Monday => "MO",
|
||||||
|
Weekday::Tuesday => "TU",
|
||||||
|
Weekday::Wednesday => "WE",
|
||||||
|
Weekday::Thursday => "TH",
|
||||||
|
Weekday::Friday => "FR",
|
||||||
|
Weekday::Saturday => "SA",
|
||||||
|
Weekday::Sunday => "SU",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(mins: u32) -> String {
|
||||||
|
let h = mins / 60;
|
||||||
|
let m = mins % 60;
|
||||||
|
if h > 0 && m > 0 {
|
||||||
|
format!("PT{}H{}M", h, m)
|
||||||
|
} else if h > 0 {
|
||||||
|
format!("PT{}H", h)
|
||||||
|
} else {
|
||||||
|
format!("PT{}M", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_line(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace(',', "\\,").replace(';', "\\;")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_folded_property(out: &mut String, name: &str, value: &str) {
|
||||||
|
let line = format!("{}:{}", name, value);
|
||||||
|
if line.len() <= 75 {
|
||||||
|
out.push_str(&line);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
out.push_str(&line[..75]);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
let mut pos = 75;
|
||||||
|
while pos < bytes.len() {
|
||||||
|
let end = (pos + 74).min(bytes.len());
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&line[pos..end]);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/ical.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod ical;
|
||||||
pub mod iptv;
|
pub mod iptv;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
|
|
||||||
|
pub use ical::generate_ical;
|
||||||
pub use iptv::{generate_m3u, generate_xmltv};
|
pub use iptv::{generate_m3u, generate_xmltv};
|
||||||
pub use schedule::ScheduleEngineService;
|
pub use schedule::ScheduleEngineService;
|
||||||
|
|||||||
145
crates/domain/src/services/tests/ical.rs
Normal file
145
crates/domain/src/services/tests/ical.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
use crate::models::{ProgrammingBlock, ScheduleConfig};
|
||||||
|
use crate::value_objects::{FillStrategy, MediaFilter, Weekday};
|
||||||
|
use chrono::NaiveTime;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn make_config_with_blocks(
|
||||||
|
entries: Vec<(Weekday, Vec<ProgrammingBlock>)>,
|
||||||
|
) -> ScheduleConfig {
|
||||||
|
let day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>> = entries.into_iter().collect();
|
||||||
|
ScheduleConfig::from_day_blocks(day_blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vcalendar_header() {
|
||||||
|
let config = ScheduleConfig::new();
|
||||||
|
let ical = generate_ical("Test Channel", "Europe/Warsaw", &config);
|
||||||
|
assert!(ical.contains("BEGIN:VCALENDAR\r\n"));
|
||||||
|
assert!(ical.contains("VERSION:2.0\r\n"));
|
||||||
|
assert!(ical.contains("PRODID:-//K-TV//Schedule Export//EN\r\n"));
|
||||||
|
assert!(ical.contains("CALSCALE:GREGORIAN\r\n"));
|
||||||
|
assert!(ical.contains("X-WR-CALNAME:Test Channel\r\n"));
|
||||||
|
assert!(ical.contains("X-WR-TIMEZONE:Europe/Warsaw\r\n"));
|
||||||
|
assert!(ical.contains("END:VCALENDAR\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_config_produces_no_events() {
|
||||||
|
let config = ScheduleConfig::new();
|
||||||
|
let ical = generate_ical("Empty", "UTC", &config);
|
||||||
|
assert!(!ical.contains("BEGIN:VEVENT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn algorithmic_block_produces_vevent() {
|
||||||
|
let block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"Morning Cartoons",
|
||||||
|
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
|
||||||
|
120,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::Random,
|
||||||
|
);
|
||||||
|
let config = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]);
|
||||||
|
let ical = generate_ical("Kids TV", "UTC", &config);
|
||||||
|
|
||||||
|
assert!(ical.contains("BEGIN:VEVENT\r\n"));
|
||||||
|
assert!(ical.contains("END:VEVENT\r\n"));
|
||||||
|
assert!(ical.contains("DTSTART:080000\r\n"));
|
||||||
|
assert!(ical.contains("DURATION:PT2H\r\n"));
|
||||||
|
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n"));
|
||||||
|
assert!(ical.contains("SUMMARY:Morning Cartoons\r\n"));
|
||||||
|
assert!(ical.contains(&format!("UID:{}\r\n", block.id())));
|
||||||
|
assert!(ical.contains("X-KTV-STRATEGY:random\r\n"));
|
||||||
|
assert!(ical.contains("X-KTV-CONTENT:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_days_produce_different_byday() {
|
||||||
|
let mon_block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"Monday Block",
|
||||||
|
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
|
||||||
|
60,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::Sequential,
|
||||||
|
);
|
||||||
|
let fri_block = ProgrammingBlock::new_algorithmic(
|
||||||
|
"Friday Block",
|
||||||
|
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
|
||||||
|
90,
|
||||||
|
MediaFilter::default(),
|
||||||
|
FillStrategy::BestFit,
|
||||||
|
);
|
||||||
|
let config = make_config_with_blocks(vec![
|
||||||
|
(Weekday::Monday, vec![mon_block]),
|
||||||
|
(Weekday::Friday, vec![fri_block]),
|
||||||
|
]);
|
||||||
|
let ical = generate_ical("Multi-Day", "America/New_York", &config);
|
||||||
|
|
||||||
|
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n"));
|
||||||
|
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=FR\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_block_has_no_strategy() {
|
||||||
|
let block = ProgrammingBlock::new_manual(
|
||||||
|
"Manual Show",
|
||||||
|
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
|
||||||
|
45,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let config = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]);
|
||||||
|
let ical = generate_ical("Manual Ch", "UTC", &config);
|
||||||
|
|
||||||
|
assert!(ical.contains("SUMMARY:Manual Show\r\n"));
|
||||||
|
assert!(ical.contains("DTSTART:143000\r\n"));
|
||||||
|
assert!(ical.contains("DURATION:PT45M\r\n"));
|
||||||
|
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=WE\r\n"));
|
||||||
|
assert!(!ical.contains("X-KTV-STRATEGY:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_with_hours_and_minutes() {
|
||||||
|
let block = ProgrammingBlock::new_manual(
|
||||||
|
"Mixed Duration",
|
||||||
|
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||||||
|
150,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let config = make_config_with_blocks(vec![(Weekday::Sunday, vec![block])]);
|
||||||
|
let ical = generate_ical("Test", "UTC", &config);
|
||||||
|
|
||||||
|
assert!(ical.contains("DURATION:PT2H30M\r\n"));
|
||||||
|
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=SU\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_weekdays_mapped() {
|
||||||
|
let days_and_byday = [
|
||||||
|
(Weekday::Monday, "MO"),
|
||||||
|
(Weekday::Tuesday, "TU"),
|
||||||
|
(Weekday::Wednesday, "WE"),
|
||||||
|
(Weekday::Thursday, "TH"),
|
||||||
|
(Weekday::Friday, "FR"),
|
||||||
|
(Weekday::Saturday, "SA"),
|
||||||
|
(Weekday::Sunday, "SU"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (day, byday) in days_and_byday {
|
||||||
|
let block = ProgrammingBlock::new_manual(
|
||||||
|
"Test",
|
||||||
|
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
|
||||||
|
60,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let config = make_config_with_blocks(vec![(day, vec![block])]);
|
||||||
|
let ical = generate_ical("Test", "UTC", &config);
|
||||||
|
assert!(
|
||||||
|
ical.contains(&format!("BYDAY={}\r\n", byday)),
|
||||||
|
"Expected BYDAY={} for {:?}",
|
||||||
|
byday,
|
||||||
|
day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -201,6 +201,16 @@ impl KTvMcpServer {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Export a channel's schedule as iCalendar (.ics). Returns RFC 5545 text."
|
||||||
|
)]
|
||||||
|
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(tool_box)]
|
#[tool(tool_box)]
|
||||||
|
|||||||
20
crates/mcp/src/tools/ical.rs
Normal file
20
crates/mcp/src/tools/ical.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::domain_err;
|
||||||
|
|
||||||
|
pub async fn export_schedule_ical(
|
||||||
|
channel_query: &Arc<dyn domain::ports::ChannelQuery>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> String {
|
||||||
|
match channel_query.find_by_id(channel_id.into()).await {
|
||||||
|
Ok(Some(channel)) => domain::generate_ical(
|
||||||
|
channel.name(),
|
||||||
|
channel.timezone(),
|
||||||
|
channel.schedule_config(),
|
||||||
|
),
|
||||||
|
Ok(None) => serde_json::json!({"error": "Channel not found"}).to_string(),
|
||||||
|
Err(e) => domain_err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod ical;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{StatusCode, header};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ use api_types::{
|
|||||||
use application::schedule::{
|
use application::schedule::{
|
||||||
GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery,
|
GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery,
|
||||||
};
|
};
|
||||||
|
use domain::DomainError;
|
||||||
use domain::value_objects::ChannelId;
|
use domain::value_objects::ChannelId;
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
@@ -91,3 +92,26 @@ pub async fn list_schedule_history(
|
|||||||
.collect(),
|
.collect(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn export_ical(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
let channel = state
|
||||||
|
.channel_query
|
||||||
|
.find_by_id(id.into())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?;
|
||||||
|
|
||||||
|
let ical = domain::generate_ical(channel.name(), channel.timezone(), channel.schedule_config());
|
||||||
|
let disposition = format!("attachment; filename=\"{}.ics\"", channel.name());
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()),
|
||||||
|
(header::CONTENT_DISPOSITION, disposition),
|
||||||
|
],
|
||||||
|
ical,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn channel_router() -> Router<AppState> {
|
|||||||
.route("/{id}/now", get(handlers::schedule::get_current_broadcast))
|
.route("/{id}/now", get(handlers::schedule::get_current_broadcast))
|
||||||
.route("/{id}/epg", get(handlers::schedule::get_epg))
|
.route("/{id}/epg", get(handlers::schedule::get_epg))
|
||||||
.route("/{id}/stream", get(handlers::schedule::get_stream))
|
.route("/{id}/stream", get(handlers::schedule::get_stream))
|
||||||
|
.route("/{id}/export.ics", get(handlers::schedule::export_ical))
|
||||||
.route("/{id}/snapshots", post(handlers::channels::save_snapshot))
|
.route("/{id}/snapshots", post(handlers::channels::save_snapshot))
|
||||||
.route("/{id}/snapshots", get(handlers::channels::list_snapshots))
|
.route("/{id}/snapshots", get(handlers::channels::list_snapshots))
|
||||||
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
|
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))
|
||||||
|
|||||||
Reference in New Issue
Block a user