feat: schedule history — list, detail, rollback endpoints
This commit is contained in:
@@ -180,6 +180,34 @@ impl From<domain::Channel> for ChannelResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Config history DTOs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ConfigSnapshotResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub version_num: i64,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::ChannelConfigSnapshot> for ConfigSnapshotResponse {
|
||||||
|
fn from(s: domain::ChannelConfigSnapshot) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id,
|
||||||
|
version_num: s.version_num,
|
||||||
|
label: s.label,
|
||||||
|
created_at: s.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchSnapshotRequest {
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EPG / playback DTOs
|
// EPG / playback DTOs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -305,6 +333,27 @@ pub struct TranscodeStatsResponse {
|
|||||||
pub item_count: usize,
|
pub item_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScheduleHistoryEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub generation: u32,
|
||||||
|
pub valid_from: DateTime<Utc>,
|
||||||
|
pub valid_until: DateTime<Utc>,
|
||||||
|
pub slot_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<domain::GeneratedSchedule> for ScheduleHistoryEntry {
|
||||||
|
fn from(s: domain::GeneratedSchedule) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id,
|
||||||
|
generation: s.generation,
|
||||||
|
valid_from: s.valid_from,
|
||||||
|
valid_until: s.valid_until,
|
||||||
|
slot_count: s.slots.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<domain::GeneratedSchedule> for ScheduleResponse {
|
impl From<domain::GeneratedSchedule> for ScheduleResponse {
|
||||||
fn from(s: domain::GeneratedSchedule) -> Self {
|
fn from(s: domain::GeneratedSchedule) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ pub fn router() -> Router<AppState> {
|
|||||||
"/{id}/schedule",
|
"/{id}/schedule",
|
||||||
post(schedule::generate_schedule).get(schedule::get_active_schedule),
|
post(schedule::generate_schedule).get(schedule::get_active_schedule),
|
||||||
)
|
)
|
||||||
|
.route("/{id}/schedule/history", get(schedule::list_schedule_history))
|
||||||
|
.route(
|
||||||
|
"/{id}/schedule/history/{gen_id}",
|
||||||
|
get(schedule::get_schedule_history_entry),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/schedule/history/{gen_id}/rollback",
|
||||||
|
post(schedule::rollback_schedule),
|
||||||
|
)
|
||||||
.route("/{id}/now", get(broadcast::get_current_broadcast))
|
.route("/{id}/now", get(broadcast::get_current_broadcast))
|
||||||
.route("/{id}/epg", get(broadcast::get_epg))
|
.route("/{id}/epg", get(broadcast::get_epg))
|
||||||
.route("/{id}/stream", get(broadcast::get_stream))
|
.route("/{id}/stream", get(broadcast::get_stream))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
|||||||
use domain::{self, DomainError};
|
use domain::{self, DomainError};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
dto::ScheduleResponse,
|
dto::{ScheduleHistoryEntry, ScheduleResponse},
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
extractors::CurrentUser,
|
extractors::CurrentUser,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -60,3 +60,75 @@ pub(super) async fn get_active_schedule(
|
|||||||
|
|
||||||
Ok(Json(ScheduleResponse::from(schedule)))
|
Ok(Json(ScheduleResponse::from(schedule)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all schedule generations for a channel, newest first.
|
||||||
|
/// Returns lightweight entries (no slots).
|
||||||
|
pub(super) async fn list_schedule_history(
|
||||||
|
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 history = state.schedule_engine.list_schedule_history(channel_id).await?;
|
||||||
|
let entries: Vec<ScheduleHistoryEntry> = history.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single historical schedule with all its slots.
|
||||||
|
pub(super) async fn get_schedule_history_entry(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path((channel_id, gen_id)): Path<(Uuid, 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_schedule_by_id(channel_id, gen_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Schedule {} not found", gen_id)))?;
|
||||||
|
|
||||||
|
Ok(Json(ScheduleResponse::from(schedule)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll back to a previous schedule generation.
|
||||||
|
///
|
||||||
|
/// Deletes all generations after `gen_id`'s generation, then generates a fresh
|
||||||
|
/// schedule from now (inheriting the rolled-back generation as the base for
|
||||||
|
/// recycle-policy history).
|
||||||
|
pub(super) async fn rollback_schedule(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path((channel_id, gen_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||||
|
require_owner(&channel, user.id)?;
|
||||||
|
|
||||||
|
let target = state
|
||||||
|
.schedule_engine
|
||||||
|
.get_schedule_by_id(channel_id, gen_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Schedule {} not found", gen_id)))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.schedule_engine
|
||||||
|
.delete_schedules_after(channel_id, target.generation)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let schedule = state
|
||||||
|
.schedule_engine
|
||||||
|
.generate_schedule(channel_id, Utc::now())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = state.event_tx.send(domain::DomainEvent::ScheduleGenerated {
|
||||||
|
channel_id,
|
||||||
|
schedule: schedule.clone(),
|
||||||
|
});
|
||||||
|
let detail = format!("rollback to gen {}; {} slots", target.generation, schedule.slots.len());
|
||||||
|
let _ = state.activity_log_repo.log("schedule_rollback", &detail, Some(channel_id)).await;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,32 @@ impl ScheduleEngineService {
|
|||||||
self.provider_registry.get_stream_url(item_id, quality).await
|
self.provider_registry.get_stream_url(item_id, quality).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all generated schedule headers for a channel, newest first.
|
||||||
|
pub async fn list_schedule_history(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
) -> DomainResult<Vec<GeneratedSchedule>> {
|
||||||
|
self.schedule_repo.list_schedule_history(channel_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a specific schedule with its slots.
|
||||||
|
pub async fn get_schedule_by_id(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
schedule_id: uuid::Uuid,
|
||||||
|
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||||
|
self.schedule_repo.get_schedule_by_id(channel_id, schedule_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all schedules with generation > target_generation for this channel.
|
||||||
|
pub async fn delete_schedules_after(
|
||||||
|
&self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
target_generation: u32,
|
||||||
|
) -> DomainResult<()> {
|
||||||
|
self.schedule_repo.delete_schedules_after(channel_id, target_generation).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Return all slots that overlap the given time window — the EPG data.
|
/// Return all slots that overlap the given time window — the EPG data.
|
||||||
pub fn get_epg(
|
pub fn get_epg(
|
||||||
schedule: &GeneratedSchedule,
|
schedule: &GeneratedSchedule,
|
||||||
|
|||||||
Reference in New Issue
Block a user