286 lines
9.1 KiB
Rust
286 lines
9.1 KiB
Rust
//! Channel routes
|
|
//!
|
|
//! CRUD + schedule generation require authentication (Bearer JWT).
|
|
//! Viewing endpoints (list, now, epg, stream) are intentionally public so the
|
|
//! TV page works without login.
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Redirect, Response},
|
|
routing::{get, post},
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use domain::{DomainError, ScheduleEngineService};
|
|
|
|
use crate::{
|
|
dto::{
|
|
ChannelResponse, CreateChannelRequest, CurrentBroadcastResponse, ScheduleResponse,
|
|
ScheduledSlotResponse, UpdateChannelRequest,
|
|
},
|
|
error::ApiError,
|
|
extractors::CurrentUser,
|
|
state::AppState,
|
|
};
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(list_channels).post(create_channel))
|
|
.route(
|
|
"/{id}",
|
|
get(get_channel).put(update_channel).delete(delete_channel),
|
|
)
|
|
.route(
|
|
"/{id}/schedule",
|
|
post(generate_schedule).get(get_active_schedule),
|
|
)
|
|
.route("/{id}/now", get(get_current_broadcast))
|
|
.route("/{id}/epg", get(get_epg))
|
|
.route("/{id}/stream", get(get_stream))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Channel CRUD
|
|
// ============================================================================
|
|
|
|
async fn list_channels(
|
|
State(state): State<AppState>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let channels = state.channel_service.find_all().await?;
|
|
let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect();
|
|
Ok(Json(response))
|
|
}
|
|
|
|
async fn create_channel(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Json(payload): Json<CreateChannelRequest>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let mut channel = state
|
|
.channel_service
|
|
.create(user.id, &payload.name, &payload.timezone)
|
|
.await?;
|
|
|
|
if let Some(desc) = payload.description {
|
|
channel.description = Some(desc);
|
|
channel = state.channel_service.update(channel).await?;
|
|
}
|
|
|
|
Ok((StatusCode::CREATED, Json(ChannelResponse::from(channel))))
|
|
}
|
|
|
|
async fn get_channel(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let channel = state.channel_service.find_by_id(channel_id).await?;
|
|
require_owner(&channel, user.id)?;
|
|
Ok(Json(ChannelResponse::from(channel)))
|
|
}
|
|
|
|
async fn update_channel(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(channel_id): Path<Uuid>,
|
|
Json(payload): Json<UpdateChannelRequest>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let mut channel = state.channel_service.find_by_id(channel_id).await?;
|
|
require_owner(&channel, user.id)?;
|
|
|
|
if let Some(name) = payload.name {
|
|
channel.name = name;
|
|
}
|
|
if let Some(desc) = payload.description {
|
|
channel.description = Some(desc);
|
|
}
|
|
if let Some(tz) = payload.timezone {
|
|
channel.timezone = tz;
|
|
}
|
|
if let Some(sc) = payload.schedule_config {
|
|
channel.schedule_config = sc;
|
|
}
|
|
if let Some(rp) = payload.recycle_policy {
|
|
channel.recycle_policy = rp;
|
|
}
|
|
channel.updated_at = Utc::now();
|
|
|
|
let channel = state.channel_service.update(channel).await?;
|
|
Ok(Json(ChannelResponse::from(channel)))
|
|
}
|
|
|
|
async fn delete_channel(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// ChannelService::delete enforces ownership internally
|
|
state.channel_service.delete(channel_id, user.id).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Schedule generation + retrieval
|
|
// ============================================================================
|
|
|
|
/// Trigger 48-hour schedule generation for a channel, starting from now.
|
|
/// Replaces any existing schedule for the same window.
|
|
async fn generate_schedule(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let channel = state.channel_service.find_by_id(channel_id).await?;
|
|
require_owner(&channel, user.id)?;
|
|
|
|
let schedule = state
|
|
.schedule_engine
|
|
.generate_schedule(channel_id, Utc::now())
|
|
.await?;
|
|
|
|
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
|
|
}
|
|
|
|
/// Return the currently active 48-hour schedule for a channel.
|
|
/// 404 if no schedule has been generated yet — call POST /:id/schedule first.
|
|
async fn get_active_schedule(
|
|
State(state): State<AppState>,
|
|
CurrentUser(user): CurrentUser,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let channel = state.channel_service.find_by_id(channel_id).await?;
|
|
require_owner(&channel, user.id)?;
|
|
|
|
let schedule = state
|
|
.schedule_engine
|
|
.get_active_schedule(channel_id, Utc::now())
|
|
.await?
|
|
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
|
|
|
Ok(Json(ScheduleResponse::from(schedule)))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Live broadcast endpoints
|
|
// ============================================================================
|
|
|
|
/// What is currently playing right now on this channel.
|
|
/// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
|
|
async fn get_current_broadcast(
|
|
State(state): State<AppState>,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<Response, ApiError> {
|
|
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
|
|
|
let now = Utc::now();
|
|
let schedule = state
|
|
.schedule_engine
|
|
.get_active_schedule(channel_id, now)
|
|
.await?
|
|
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
|
|
|
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
|
|
None => Ok(StatusCode::NO_CONTENT.into_response()),
|
|
Some(broadcast) => Ok(Json(CurrentBroadcastResponse {
|
|
slot: broadcast.slot.into(),
|
|
offset_secs: broadcast.offset_secs,
|
|
})
|
|
.into_response()),
|
|
}
|
|
}
|
|
|
|
/// EPG: return scheduled slots that overlap a time window.
|
|
///
|
|
/// Query params (both RFC3339, both optional):
|
|
/// - `from` — start of window (default: now)
|
|
/// - `until` — end of window (default: now + 4 hours)
|
|
#[derive(Debug, Deserialize)]
|
|
struct EpgQuery {
|
|
from: Option<String>,
|
|
until: Option<String>,
|
|
}
|
|
|
|
async fn get_epg(
|
|
State(state): State<AppState>,
|
|
Path(channel_id): Path<Uuid>,
|
|
Query(params): Query<EpgQuery>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
|
|
|
let now = Utc::now();
|
|
let from = parse_optional_dt(params.from, now)?;
|
|
let until = parse_optional_dt(params.until, now + chrono::Duration::hours(4))?;
|
|
|
|
if until <= from {
|
|
return Err(ApiError::validation("'until' must be after 'from'"));
|
|
}
|
|
|
|
let schedule = state
|
|
.schedule_engine
|
|
.get_active_schedule(channel_id, from)
|
|
.await?
|
|
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
|
|
|
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
|
|
.into_iter()
|
|
.cloned()
|
|
.map(Into::into)
|
|
.collect();
|
|
|
|
Ok(Json(slots))
|
|
}
|
|
|
|
/// Redirect to the stream URL for whatever is currently playing.
|
|
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
|
|
/// Returns 204 No Content when the channel is in a gap (no-signal).
|
|
async fn get_stream(
|
|
State(state): State<AppState>,
|
|
Path(channel_id): Path<Uuid>,
|
|
) -> Result<Response, ApiError> {
|
|
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
|
|
|
let now = Utc::now();
|
|
let schedule = state
|
|
.schedule_engine
|
|
.get_active_schedule(channel_id, now)
|
|
.await?
|
|
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
|
|
|
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
|
|
None => return Ok(StatusCode::NO_CONTENT.into_response()),
|
|
Some(b) => b,
|
|
};
|
|
|
|
let url = state
|
|
.schedule_engine
|
|
.get_stream_url(&broadcast.slot.item.id)
|
|
.await?;
|
|
|
|
Ok(Redirect::temporary(&url).into_response())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<(), ApiError> {
|
|
if channel.owner_id != user_id {
|
|
Err(ApiError::Forbidden("You don't own this channel".into()))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn parse_optional_dt(s: Option<String>, default: DateTime<Utc>) -> Result<DateTime<Utc>, ApiError> {
|
|
match s {
|
|
None => Ok(default),
|
|
Some(raw) => DateTime::parse_from_rfc3339(&raw)
|
|
.map(|dt| dt.with_timezone(&Utc))
|
|
.map_err(|_| ApiError::validation(format!("Invalid datetime '{}' — use RFC3339", raw))),
|
|
}
|
|
}
|