//! IPTV export routes //! //! Generates M3U playlists and XMLTV guides for use with standard IPTV clients. //! Auth is provided via `?token=` 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 { Router::new() .route("/playlist.m3u", get(get_playlist)) .route("/epg.xml", get(get_epg)) } #[derive(Debug, Deserialize)] struct TokenQuery { token: Option, } /// `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, Query(params): Query, ) -> Result { 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, Query(params): Query, ) -> Result { 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 { 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(), )) } }