//! 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 { 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, ) -> Result { let channels = state.channel_service.find_all().await?; let response: Vec = channels.into_iter().map(Into::into).collect(); Ok(Json(response)) } async fn create_channel( State(state): State, CurrentUser(user): CurrentUser, Json(payload): Json, ) -> Result { 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, CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { 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, CurrentUser(user): CurrentUser, Path(channel_id): Path, Json(payload): Json, ) -> Result { 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, CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { // 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, CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { 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, CurrentUser(user): CurrentUser, Path(channel_id): Path, ) -> Result { 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, Path(channel_id): Path, ) -> Result { 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, until: Option, } async fn get_epg( State(state): State, Path(channel_id): Path, Query(params): Query, ) -> Result { 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 = 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, Path(channel_id): Path, ) -> Result { 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, default: DateTime) -> Result, 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))), } }