feat: add IPTV export functionality with M3U and XMLTV generation, including UI components for export dialog
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
118
k-tv-backend/api/src/routes/iptv.rs
Normal file
118
k-tv-backend/api/src/routes/iptv.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user