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

@@ -39,6 +39,9 @@ pub struct Config {
pub jellyfin_base_url: Option<String>,
pub jellyfin_api_key: Option<String>,
pub jellyfin_user_id: Option<String>,
/// Public base URL of this API server (used to build IPTV stream URLs).
pub base_url: String,
}
impl Config {
@@ -111,6 +114,9 @@ impl Config {
let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok();
let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok();
let base_url = env::var("BASE_URL")
.unwrap_or_else(|_| format!("http://localhost:{}", port));
Self {
host,
port,
@@ -134,6 +140,7 @@ impl Config {
jellyfin_base_url,
jellyfin_api_key,
jellyfin_user_id,
base_url,
}
}
}

View File

@@ -39,6 +39,9 @@ impl FromRequestParts<AppState> for CurrentUser {
}
/// Optional current user — returns None instead of error when auth is missing/invalid.
///
/// Checks `Authorization: Bearer <token>` first; falls back to `?token=<jwt>` query param
/// so IPTV clients and direct stream links work without custom headers.
pub struct OptionalCurrentUser(pub Option<User>);
impl FromRequestParts<AppState> for OptionalCurrentUser {
@@ -50,7 +53,21 @@ impl FromRequestParts<AppState> for OptionalCurrentUser {
) -> Result<Self, Self::Rejection> {
#[cfg(feature = "auth-jwt")]
{
return Ok(OptionalCurrentUser(try_jwt_auth(parts, state).await.ok()));
// Try Authorization header first
if let Ok(user) = try_jwt_auth(parts, state).await {
return Ok(OptionalCurrentUser(Some(user)));
}
// Fall back to ?token= query param
let query_token = parts.uri.query().and_then(|q| {
q.split('&')
.find(|seg| seg.starts_with("token="))
.map(|seg| seg[6..].to_owned())
});
if let Some(token) = query_token {
let user = validate_jwt_token(&token, state).await.ok();
return Ok(OptionalCurrentUser(user));
}
return Ok(OptionalCurrentUser(None));
}
#[cfg(not(feature = "auth-jwt"))]
@@ -61,7 +78,7 @@ impl FromRequestParts<AppState> for OptionalCurrentUser {
}
}
/// Authenticate using JWT Bearer token
/// Authenticate using JWT Bearer token from the `Authorization` header.
#[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
use axum::http::header::AUTHORIZATION;
@@ -79,6 +96,12 @@ async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiEr
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
})?;
validate_jwt_token(token, state).await
}
/// Validate a raw JWT string and return the corresponding `User`.
#[cfg(feature = "auth-jwt")]
pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result<User, ApiError> {
let validator = state
.jwt_validator
.as_ref()

View File

@@ -0,0 +1,118 @@
//! IPTV export routes
//!
//! Generates M3U playlists and XMLTV guides for use with standard IPTV clients.
//! Auth is provided via `?token=<jwt>` query param so URLs can be pasted
//! directly into TiviMate, VLC, etc.
use std::collections::HashMap;
use axum::{
Router,
extract::{Query, State},
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use chrono::Utc;
use serde::Deserialize;
use crate::{error::ApiError, state::AppState};
#[cfg(feature = "auth-jwt")]
use crate::extractors::validate_jwt_token;
pub fn router() -> Router<AppState> {
Router::new()
.route("/playlist.m3u", get(get_playlist))
.route("/epg.xml", get(get_epg))
}
#[derive(Debug, Deserialize)]
struct TokenQuery {
token: Option<String>,
}
/// `GET /api/v1/iptv/playlist.m3u?token={jwt}`
///
/// Returns an M3U playlist with one entry per channel the authenticated user owns.
async fn get_playlist(
State(state): State<AppState>,
Query(params): Query<TokenQuery>,
) -> Result<Response, ApiError> {
let token = params
.token
.as_deref()
.unwrap_or("")
.to_owned();
let user = authenticate_query_token(&token, &state).await?;
let channels = state.channel_service.find_by_owner(user.id).await?;
let body = domain::generate_m3u(&channels, &state.config.base_url, &token);
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("audio/x-mpegurl"))],
body,
)
.into_response())
}
/// `GET /api/v1/iptv/epg.xml?token={jwt}`
///
/// Returns an XMLTV document covering the active schedule for all channels
/// owned by the authenticated user.
async fn get_epg(
State(state): State<AppState>,
Query(params): Query<TokenQuery>,
) -> Result<Response, ApiError> {
let token = params.token.as_deref().unwrap_or("").to_owned();
let user = authenticate_query_token(&token, &state).await?;
let channels = state.channel_service.find_by_owner(user.id).await?;
let now = Utc::now();
let mut slots_by_channel = HashMap::new();
for ch in &channels {
if let Ok(Some(schedule)) = state.schedule_engine.get_active_schedule(ch.id, now).await {
slots_by_channel.insert(ch.id, schedule.slots);
}
}
let body = domain::generate_xmltv(&channels, &slots_by_channel);
Ok((
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/xml; charset=utf-8"),
)],
body,
)
.into_response())
}
/// Validate a JWT from the `?token=` query param and return the user.
async fn authenticate_query_token(
token: &str,
state: &AppState,
) -> Result<domain::User, ApiError> {
if token.is_empty() {
return Err(ApiError::Unauthorized(
"Missing ?token= query parameter".to_string(),
));
}
#[cfg(feature = "auth-jwt")]
{
return validate_jwt_token(token, state).await;
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (token, state);
Err(ApiError::Unauthorized(
"No authentication backend configured".to_string(),
))
}
}

View File

@@ -8,6 +8,7 @@ use axum::Router;
pub mod auth;
pub mod channels;
pub mod config;
pub mod iptv;
pub mod library;
/// Construct the API v1 router
@@ -16,5 +17,6 @@ pub fn api_v1_router() -> Router<AppState> {
.nest("/auth", auth::router())
.nest("/channels", channels::router())
.nest("/config", config::router())
.nest("/iptv", iptv::router())
.nest("/library", library::router())
}

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