feat: add IPTV export functionality with M3U and XMLTV generation, including UI components for export dialog
This commit is contained in:
93
k-tv-backend/domain/src/iptv.rs
Normal file
93
k-tv-backend/domain/src/iptv.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! 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<ChannelId, Vec<ScheduledSlot>>,
|
||||
) -> String {
|
||||
let mut out =
|
||||
String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tv generator-info-name=\"k-tv\">\n");
|
||||
|
||||
for ch in channels {
|
||||
out.push_str(&format!(
|
||||
" <channel id=\"{}\"><display-name>{}</display-name></channel>\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!(
|
||||
" <programme start=\"{}\" stop=\"{}\" channel=\"{}\">\n",
|
||||
start, stop, ch.id
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" <title lang=\"en\">{}</title>\n",
|
||||
escape_xml(&slot.item.title)
|
||||
));
|
||||
if let Some(desc) = &slot.item.description {
|
||||
out.push_str(&format!(
|
||||
" <desc lang=\"en\">{}</desc>\n",
|
||||
escape_xml(desc)
|
||||
));
|
||||
}
|
||||
if let Some(genre) = slot.item.genres.first() {
|
||||
out.push_str(&format!(
|
||||
" <category lang=\"en\">{}</category>\n",
|
||||
escape_xml(genre)
|
||||
));
|
||||
}
|
||||
if let (Some(season), Some(episode)) =
|
||||
(slot.item.season_number, slot.item.episode_number)
|
||||
{
|
||||
out.push_str(&format!(
|
||||
" <episode-num system=\"onscreen\">S{}E{}</episode-num>\n",
|
||||
season, episode
|
||||
));
|
||||
}
|
||||
out.push_str(" </programme>\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("</tv>\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
Reference in New Issue
Block a user