feat: add IPTV export functionality with M3U and XMLTV generation, including UI components for export dialog

This commit is contained in:
2026-03-14 02:11:20 +01:00
parent 66ec0c51c0
commit e610c23fea
9 changed files with 462 additions and 32 deletions

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}

View File

@@ -5,6 +5,7 @@
pub mod entities;
pub mod errors;
pub mod iptv;
pub mod ports;
pub mod repositories;
pub mod services;
@@ -15,5 +16,6 @@ pub use entities::*;
pub use errors::{DomainError, DomainResult};
pub use ports::{Collection, IMediaProvider, SeriesSummary};
pub use repositories::*;
pub use iptv::{generate_m3u, generate_xmltv};
pub use services::{ChannelService, ScheduleEngineService, UserService};
pub use value_objects::*;