119 lines
3.1 KiB
Rust
119 lines
3.1 KiB
Rust
//! 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(),
|
|
))
|
|
}
|
|
}
|