iCalendar export: domain service + API + MCP tool (#15)

This commit is contained in:
2026-07-12 14:27:20 +02:00
parent affd7f0223
commit b00272aceb
9 changed files with 315 additions and 3 deletions

View File

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

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

View File

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

View 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
);
}
}

View File

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

View 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),
}
}

View File

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

View File

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

View File

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