//! IPTV export: M3U playlist and XMLTV guide generation. //! //! Pure functions — no I/O, no dependencies beyond domain types. use std::collections::HashMap; use crate::entities::{Channel, ScheduledSlot}; use crate::value_objects::ChannelId; /// Generate an M3U playlist for the given channels. /// /// Each entry points to the channel's `/stream` endpoint authenticated with the /// provided JWT token so IPTV clients can load it directly. pub fn generate_m3u(channels: &[Channel], base_url: &str, token: &str) -> String { let mut out = String::from("#EXTM3U\n"); for ch in channels { out.push_str(&format!( "#EXTINF:-1 tvg-id=\"{}\" tvg-name=\"{}\" tvg-logo=\"\" group-title=\"K-TV\",{}\n", ch.id, ch.name, ch.name )); out.push_str(&format!( "{}/api/v1/channels/{}/stream?token={}\n", base_url, ch.id, token )); } out } /// Generate an XMLTV EPG document for the given channels and their scheduled slots. pub fn generate_xmltv( channels: &[Channel], slots_by_channel: &HashMap>, ) -> String { let mut out = String::from("\n\n"); for ch in channels { out.push_str(&format!( " {}\n", ch.id, escape_xml(&ch.name) )); } for ch in channels { if let Some(slots) = slots_by_channel.get(&ch.id) { for slot in slots { let start = slot.start_at.format("%Y%m%d%H%M%S +0000"); let stop = slot.end_at.format("%Y%m%d%H%M%S +0000"); out.push_str(&format!( " \n", start, stop, ch.id )); out.push_str(&format!( " {}\n", escape_xml(&slot.item.title) )); if let Some(desc) = &slot.item.description { out.push_str(&format!( " {}\n", escape_xml(desc) )); } if let Some(genre) = slot.item.genres.first() { out.push_str(&format!( " {}\n", escape_xml(genre) )); } if let (Some(season), Some(episode)) = (slot.item.season_number, slot.item.episode_number) { out.push_str(&format!( " S{}E{}\n", season, episode )); } out.push_str(" \n"); } } } out.push_str("\n"); out } fn escape_xml(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }