multi-channel playout: concurrent tick, status endpoint, ffmpeg restart (#11)

This commit is contained in:
2026-07-12 13:59:54 +02:00
parent 1b3ecc10e1
commit 84dd05a8a6
4 changed files with 64 additions and 15 deletions

View File

@@ -7,6 +7,7 @@ use domain::models::{CurrentBroadcast, GeneratedSchedule};
use domain::ports::ScheduleQuery;
use domain::value_objects::{ChannelId, SourceUri};
use domain::{ScheduleEngineService, SlotId};
use serde::Serialize;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
@@ -24,6 +25,14 @@ struct ChannelState {
schedule_id: domain::value_objects::ScheduleId,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChannelStatus {
pub channel_id: String,
pub current_slot_id: String,
pub schedule_id: String,
pub ffmpeg_running: bool,
}
pub struct PlayoutEngine {
config: PlayoutConfig,
schedule_query: Arc<dyn ScheduleQuery>,
@@ -49,6 +58,13 @@ impl PlayoutEngine {
}
pub async fn add_channel(&self, channel_id: ChannelId) -> anyhow::Result<()> {
{
let channels = self.channels.read().await;
if channels.contains_key(&channel_id) {
return Err(anyhow::anyhow!("channel {channel_id} already active"));
}
}
let now = Utc::now();
let schedule = self
.schedule_query
@@ -84,13 +100,32 @@ impl PlayoutEngine {
self.channels.read().await.keys().copied().collect()
}
pub async fn channel_statuses(&self) -> Vec<ChannelStatus> {
let mut channels = self.channels.write().await;
channels
.iter_mut()
.map(|(id, state)| ChannelStatus {
channel_id: id.to_string(),
current_slot_id: state.current_slot_id.to_string(),
schedule_id: state.schedule_id.to_string(),
ffmpeg_running: state.ffmpeg.is_running(),
})
.collect()
}
pub async fn tick(&self) {
let now = Utc::now();
let channel_ids: Vec<ChannelId> = self.channels.read().await.keys().copied().collect();
for channel_id in channel_ids {
if let Err(e) = self.tick_channel(channel_id, now).await {
warn!(%channel_id, %e, "tick failed");
let futures: Vec<_> = channel_ids
.into_iter()
.map(|id| self.tick_channel(id, now))
.collect();
let results = futures::future::join_all(futures).await;
for result in results {
if let Err(e) = result {
warn!(%e, "channel tick failed");
}
}
}
@@ -125,8 +160,21 @@ impl PlayoutEngine {
.unwrap_or(false)
};
if needs_transition {
info!(%channel_id, %current_slot_id, "slot transition");
let ffmpeg_dead = {
let mut channels = self.channels.write().await;
channels
.get_mut(&channel_id)
.map(|s| !s.ffmpeg.is_running())
.unwrap_or(false)
};
if needs_transition || ffmpeg_dead {
if needs_transition {
info!(%channel_id, %current_slot_id, "slot transition");
} else {
warn!(%channel_id, "ffmpeg process died, restarting");
}
let mut channels = self.channels.write().await;
if let Some(state) = channels.get_mut(&channel_id) {
state.ffmpeg.stop().await;
@@ -176,11 +224,7 @@ impl PlayoutEngine {
segment_prefix: "seg".into(),
};
let handle = FfmpegHandle::spawn(
config,
self.store.clone(),
channel_id.to_string(),
);
let handle = FfmpegHandle::spawn(config, self.store.clone(), channel_id.to_string());
Ok(handle)
}

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, delete};
use axum::routing::{delete, get, post};
use axum::Router;
use domain::value_objects::ChannelId;
@@ -25,11 +25,9 @@ pub fn router(state: AppState) -> Router {
"/playout/{channel_id}/playlist.m3u8",
get(get_playlist),
)
.route(
"/playout/{channel_id}/{segment}",
get(get_segment),
)
.route("/playout/{channel_id}/{segment}", get(get_segment))
.route("/playout/channels", get(list_channels))
.route("/playout/channels/status", get(channel_statuses))
.route("/playout/channels/{channel_id}", post(add_channel))
.route(
"/playout/channels/{channel_id}",
@@ -85,6 +83,11 @@ async fn list_channels(State(state): State<AppState>) -> Response {
axum::Json(ids).into_response()
}
async fn channel_statuses(State(state): State<AppState>) -> Response {
let statuses = state.engine.channel_statuses().await;
axum::Json(statuses).into_response()
}
async fn add_channel(
State(state): State<AppState>,
Path(channel_id): Path<String>,