webhooks (#1)
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -72,6 +72,8 @@ pub struct CreateChannelRequest {
|
||||
pub access_mode: Option<domain::AccessMode>,
|
||||
/// Plain-text password; hashed before storage.
|
||||
pub access_password: Option<String>,
|
||||
pub webhook_url: Option<String>,
|
||||
pub webhook_poll_interval_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// All fields are optional — only provided fields are updated.
|
||||
@@ -91,6 +93,9 @@ pub struct UpdateChannelRequest {
|
||||
pub logo: Option<Option<String>>,
|
||||
pub logo_position: Option<domain::LogoPosition>,
|
||||
pub logo_opacity: Option<f32>,
|
||||
/// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged.
|
||||
pub webhook_url: Option<Option<String>>,
|
||||
pub webhook_poll_interval_secs: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -107,6 +112,8 @@ pub struct ChannelResponse {
|
||||
pub logo: Option<String>,
|
||||
pub logo_position: domain::LogoPosition,
|
||||
pub logo_opacity: f32,
|
||||
pub webhook_url: Option<String>,
|
||||
pub webhook_poll_interval_secs: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -126,6 +133,8 @@ impl From<domain::Channel> for ChannelResponse {
|
||||
logo: c.logo,
|
||||
logo_position: c.logo_position,
|
||||
logo_opacity: c.logo_opacity,
|
||||
webhook_url: c.webhook_url,
|
||||
webhook_poll_interval_secs: c.webhook_poll_interval_secs,
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
}
|
||||
|
||||
12
k-tv-backend/api/src/events.rs
Normal file
12
k-tv-backend/api/src/events.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Event bus type alias.
|
||||
//!
|
||||
//! The broadcast sender is kept in `AppState` and cloned into each route handler.
|
||||
//! Receivers are created with `event_tx.subscribe()`.
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use domain::DomainEvent;
|
||||
|
||||
/// A sender half of the domain-event broadcast channel.
|
||||
///
|
||||
/// Clone to share across tasks. Use `event_tx.subscribe()` to create receivers.
|
||||
pub type EventBus = broadcast::Sender<DomainEvent>;
|
||||
@@ -21,10 +21,13 @@ use tracing::info;
|
||||
mod config;
|
||||
mod dto;
|
||||
mod error;
|
||||
mod events;
|
||||
mod extractors;
|
||||
mod poller;
|
||||
mod routes;
|
||||
mod scheduler;
|
||||
mod state;
|
||||
mod webhook;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::AppState;
|
||||
@@ -150,7 +153,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let registry = Arc::new(registry);
|
||||
|
||||
let (event_tx, event_rx) = tokio::sync::broadcast::channel::<domain::DomainEvent>(64);
|
||||
|
||||
let bg_channel_repo = channel_repo.clone();
|
||||
let webhook_channel_repo = channel_repo.clone();
|
||||
tokio::spawn(webhook::run_webhook_consumer(
|
||||
event_rx,
|
||||
webhook_channel_repo,
|
||||
reqwest::Client::new(),
|
||||
));
|
||||
|
||||
let schedule_engine = ScheduleEngineService::new(
|
||||
Arc::clone(®istry) as Arc<dyn IProviderRegistry>,
|
||||
channel_repo,
|
||||
@@ -164,6 +176,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
schedule_engine,
|
||||
registry,
|
||||
config.clone(),
|
||||
event_tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -178,8 +191,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
cors_origins: config.cors_allowed_origins.clone(),
|
||||
};
|
||||
|
||||
let bg_channel_repo_poller = bg_channel_repo.clone();
|
||||
let bg_schedule_engine = Arc::clone(&state.schedule_engine);
|
||||
tokio::spawn(scheduler::run_auto_scheduler(bg_schedule_engine, bg_channel_repo));
|
||||
tokio::spawn(scheduler::run_auto_scheduler(bg_schedule_engine, bg_channel_repo, event_tx.clone()));
|
||||
|
||||
let bg_schedule_engine_poller = Arc::clone(&state.schedule_engine);
|
||||
tokio::spawn(poller::run_broadcast_poller(
|
||||
bg_schedule_engine_poller,
|
||||
bg_channel_repo_poller,
|
||||
event_tx,
|
||||
));
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", routes::api_v1_router())
|
||||
|
||||
432
k-tv-backend/api/src/poller.rs
Normal file
432
k-tv-backend/api/src/poller.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
//! BroadcastPoller background task.
|
||||
//!
|
||||
//! Polls each channel that has a webhook_url configured. On each tick (every 1s)
|
||||
//! it checks which channels are due for a poll (elapsed >= webhook_poll_interval_secs)
|
||||
//! and emits BroadcastTransition or NoSignal events when the current slot changes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{ChannelRepository, DomainError, DomainEvent, ScheduleEngineService};
|
||||
|
||||
/// Per-channel poller state.
|
||||
#[derive(Debug)]
|
||||
struct ChannelPollState {
|
||||
/// ID of the last slot we saw as current (None = no signal).
|
||||
last_slot_id: Option<Uuid>,
|
||||
/// Wall-clock instant of the last poll for this channel.
|
||||
last_checked: Instant,
|
||||
}
|
||||
|
||||
/// Polls channels with webhook URLs and emits broadcast transition events.
|
||||
pub async fn run_broadcast_poller(
|
||||
schedule_engine: Arc<ScheduleEngineService>,
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
event_tx: broadcast::Sender<DomainEvent>,
|
||||
) {
|
||||
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
poll_tick(&schedule_engine, &channel_repo, &event_tx, &mut state).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn poll_tick(
|
||||
schedule_engine: &Arc<ScheduleEngineService>,
|
||||
channel_repo: &Arc<dyn ChannelRepository>,
|
||||
event_tx: &broadcast::Sender<DomainEvent>,
|
||||
state: &mut HashMap<Uuid, ChannelPollState>,
|
||||
) {
|
||||
let channels = match channel_repo.find_all().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("broadcast poller: failed to load channels: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove deleted channels from state
|
||||
let live_ids: std::collections::HashSet<Uuid> = channels.iter().map(|c| c.id).collect();
|
||||
state.retain(|id, _| live_ids.contains(id));
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
for channel in channels {
|
||||
// Only poll channels with a configured webhook URL
|
||||
if channel.webhook_url.is_none() {
|
||||
state.remove(&channel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let poll_interval = Duration::from_secs(channel.webhook_poll_interval_secs as u64);
|
||||
|
||||
let entry = state.entry(channel.id).or_insert_with(|| ChannelPollState {
|
||||
last_slot_id: None,
|
||||
last_checked: Instant::now() - poll_interval, // trigger immediately on first encounter
|
||||
});
|
||||
|
||||
if entry.last_checked.elapsed() < poll_interval {
|
||||
continue; // Not yet due for a poll
|
||||
}
|
||||
|
||||
entry.last_checked = Instant::now();
|
||||
|
||||
// Find the current slot
|
||||
let current_slot_id = match schedule_engine.get_active_schedule(channel.id, now).await {
|
||||
Ok(Some(schedule)) => {
|
||||
schedule
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.start_at <= now && now < s.end_at)
|
||||
.map(|s| s.id)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(DomainError::NoActiveSchedule(_)) => None,
|
||||
Err(DomainError::ChannelNotFound(_)) => {
|
||||
state.remove(&channel.id);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"broadcast poller: error checking schedule for channel {}: {}",
|
||||
channel.id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if current_slot_id == entry.last_slot_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// State changed — emit appropriate event
|
||||
match ¤t_slot_id {
|
||||
Some(slot_id) => {
|
||||
if let Ok(Some(schedule)) = schedule_engine.get_active_schedule(channel.id, now).await {
|
||||
if let Some(slot) = schedule.slots.iter().find(|s| s.id == *slot_id).cloned() {
|
||||
let _ = event_tx.send(DomainEvent::BroadcastTransition {
|
||||
channel_id: channel.id,
|
||||
slot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = event_tx.send(DomainEvent::NoSignal {
|
||||
channel_id: channel.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entry.last_slot_id = current_slot_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use domain::{
|
||||
Channel, ChannelRepository, Collection, DomainResult, GeneratedSchedule, IProviderRegistry,
|
||||
MediaFilter, MediaItem, MediaItemId, PlaybackRecord, ProviderCapabilities,
|
||||
ScheduleEngineService, ScheduleRepository, SeriesSummary, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
use domain::value_objects::{ChannelId, ContentType, UserId};
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct MockChannelRepo {
|
||||
channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelRepository for MockChannelRepo {
|
||||
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>> {
|
||||
Ok(self.channels.iter().find(|c| c.id == id).cloned())
|
||||
}
|
||||
async fn find_by_owner(&self, _owner_id: UserId) -> DomainResult<Vec<Channel>> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
|
||||
Ok(self.channels.clone())
|
||||
}
|
||||
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn save(&self, _channel: &Channel) -> DomainResult<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _id: ChannelId) -> DomainResult<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct MockScheduleRepo {
|
||||
active: Option<GeneratedSchedule>,
|
||||
saved: Arc<Mutex<Vec<GeneratedSchedule>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScheduleRepository for MockScheduleRepo {
|
||||
async fn find_active(
|
||||
&self,
|
||||
_channel_id: ChannelId,
|
||||
_at: DateTime<Utc>,
|
||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||
Ok(self.active.clone())
|
||||
}
|
||||
async fn find_latest(&self, _channel_id: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
|
||||
Ok(self.active.clone())
|
||||
}
|
||||
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
|
||||
self.saved.lock().unwrap().push(schedule.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn find_playback_history(&self, _channel_id: ChannelId) -> DomainResult<Vec<PlaybackRecord>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn save_playback_record(&self, _record: &PlaybackRecord) -> DomainResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockRegistry;
|
||||
|
||||
#[async_trait]
|
||||
impl IProviderRegistry for MockRegistry {
|
||||
async fn fetch_items(&self, _provider_id: &str, _filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_stream_url(&self, _item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult<String> {
|
||||
unimplemented!()
|
||||
}
|
||||
fn provider_ids(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
fn primary_id(&self) -> &str {
|
||||
""
|
||||
}
|
||||
fn capabilities(&self, _provider_id: &str) -> Option<ProviderCapabilities> {
|
||||
None
|
||||
}
|
||||
async fn list_collections(&self, _provider_id: &str) -> DomainResult<Vec<Collection>> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_series(&self, _provider_id: &str, _collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_genres(&self, _provider_id: &str, _content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_channel_with_webhook(channel_id: Uuid) -> Channel {
|
||||
let mut ch = Channel::new(Uuid::new_v4(), "Test", "UTC");
|
||||
ch.id = channel_id;
|
||||
ch.webhook_url = Some("http://example.com/hook".to_string());
|
||||
ch.webhook_poll_interval_secs = 0; // always due
|
||||
ch
|
||||
}
|
||||
|
||||
fn make_slot(channel_id: Uuid, slot_id: Uuid) -> domain::ScheduledSlot {
|
||||
use domain::entities::MediaItem;
|
||||
let now = Utc::now();
|
||||
domain::ScheduledSlot {
|
||||
id: slot_id,
|
||||
start_at: now - Duration::minutes(1),
|
||||
end_at: now + Duration::minutes(29),
|
||||
item: MediaItem {
|
||||
id: MediaItemId::new("test-item"),
|
||||
title: "Test Movie".to_string(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs: 1800,
|
||||
description: None,
|
||||
genres: vec![],
|
||||
year: None,
|
||||
tags: vec![],
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
},
|
||||
source_block_id: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_schedule(channel_id: Uuid, slots: Vec<domain::ScheduledSlot>) -> GeneratedSchedule {
|
||||
let now = Utc::now();
|
||||
GeneratedSchedule {
|
||||
id: Uuid::new_v4(),
|
||||
channel_id,
|
||||
valid_from: now - Duration::hours(1),
|
||||
valid_until: now + Duration::hours(47),
|
||||
generation: 1,
|
||||
slots,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine(
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
schedule_repo: Arc<dyn ScheduleRepository>,
|
||||
) -> Arc<ScheduleEngineService> {
|
||||
Arc::new(ScheduleEngineService::new(
|
||||
Arc::new(MockRegistry),
|
||||
channel_repo,
|
||||
schedule_repo,
|
||||
))
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_transition_emitted_on_slot_change() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let slot_id = Uuid::new_v4();
|
||||
let ch = make_channel_with_webhook(channel_id);
|
||||
let slot = make_slot(channel_id, slot_id);
|
||||
let schedule = make_schedule(channel_id, vec![slot]);
|
||||
|
||||
let channel_repo: Arc<dyn ChannelRepository> =
|
||||
Arc::new(MockChannelRepo { channels: vec![ch] });
|
||||
let schedule_repo: Arc<dyn ScheduleRepository> = Arc::new(MockScheduleRepo {
|
||||
active: Some(schedule),
|
||||
saved: Arc::new(Mutex::new(vec![])),
|
||||
});
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
let (event_tx, mut event_rx) = broadcast::channel(8);
|
||||
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
|
||||
|
||||
poll_tick(&engine, &channel_repo, &event_tx, &mut state).await;
|
||||
|
||||
let event = event_rx.try_recv().expect("expected an event");
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { channel_id: cid, slot: s } => {
|
||||
assert_eq!(cid, channel_id);
|
||||
assert_eq!(s.id, slot_id);
|
||||
}
|
||||
other => panic!("expected BroadcastTransition, got something else"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_event_when_slot_unchanged() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let slot_id = Uuid::new_v4();
|
||||
let ch = make_channel_with_webhook(channel_id);
|
||||
let slot = make_slot(channel_id, slot_id);
|
||||
let schedule = make_schedule(channel_id, vec![slot]);
|
||||
|
||||
let channel_repo: Arc<dyn ChannelRepository> =
|
||||
Arc::new(MockChannelRepo { channels: vec![ch] });
|
||||
let schedule_repo: Arc<dyn ScheduleRepository> = Arc::new(MockScheduleRepo {
|
||||
active: Some(schedule),
|
||||
saved: Arc::new(Mutex::new(vec![])),
|
||||
});
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
let (event_tx, mut event_rx) = broadcast::channel(8);
|
||||
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
|
||||
|
||||
// First tick — emits BroadcastTransition
|
||||
poll_tick(&engine, &channel_repo, &event_tx, &mut state).await;
|
||||
let _ = event_rx.try_recv();
|
||||
|
||||
// Second tick — same slot, no event
|
||||
poll_tick(&engine, &channel_repo, &event_tx, &mut state).await;
|
||||
assert!(
|
||||
event_rx.try_recv().is_err(),
|
||||
"no event expected when slot unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_signal_emitted_when_slot_goes_to_none() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let slot_id = Uuid::new_v4();
|
||||
let ch = make_channel_with_webhook(channel_id);
|
||||
let slot = make_slot(channel_id, slot_id);
|
||||
let schedule_with_slot = make_schedule(channel_id, vec![slot]);
|
||||
|
||||
// Repo that starts with a slot then returns empty schedule
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
struct SwitchingScheduleRepo {
|
||||
first: GeneratedSchedule,
|
||||
second: GeneratedSchedule,
|
||||
called: AtomicBool,
|
||||
}
|
||||
#[async_trait]
|
||||
impl ScheduleRepository for SwitchingScheduleRepo {
|
||||
async fn find_active(
|
||||
&self,
|
||||
_channel_id: ChannelId,
|
||||
_at: DateTime<Utc>,
|
||||
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||
if self.called.swap(true, Ordering::SeqCst) {
|
||||
Ok(Some(self.second.clone()))
|
||||
} else {
|
||||
Ok(Some(self.first.clone()))
|
||||
}
|
||||
}
|
||||
async fn find_latest(&self, _: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn save(&self, _: &GeneratedSchedule) -> DomainResult<()> { Ok(()) }
|
||||
async fn find_playback_history(&self, _: ChannelId) -> DomainResult<Vec<PlaybackRecord>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn save_playback_record(&self, _: &PlaybackRecord) -> DomainResult<()> { Ok(()) }
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let empty_schedule = GeneratedSchedule {
|
||||
id: Uuid::new_v4(),
|
||||
channel_id,
|
||||
valid_from: now - Duration::hours(1),
|
||||
valid_until: now + Duration::hours(47),
|
||||
generation: 2,
|
||||
slots: vec![], // no current slot
|
||||
};
|
||||
|
||||
let channel_repo: Arc<dyn ChannelRepository> =
|
||||
Arc::new(MockChannelRepo { channels: vec![ch] });
|
||||
let schedule_repo: Arc<dyn ScheduleRepository> = Arc::new(SwitchingScheduleRepo {
|
||||
first: schedule_with_slot,
|
||||
second: empty_schedule,
|
||||
called: AtomicBool::new(false),
|
||||
});
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
let (event_tx, mut event_rx) = broadcast::channel(8);
|
||||
let mut state: HashMap<Uuid, ChannelPollState> = HashMap::new();
|
||||
|
||||
// First tick — emits BroadcastTransition (slot present)
|
||||
poll_tick(&engine, &channel_repo, &event_tx, &mut state).await;
|
||||
let _ = event_rx.try_recv();
|
||||
|
||||
// Second tick — schedule has no current slot, emits NoSignal
|
||||
poll_tick(&engine, &channel_repo, &event_tx, &mut state).await;
|
||||
let event = event_rx.try_recv().expect("expected NoSignal event");
|
||||
match event {
|
||||
DomainEvent::NoSignal { channel_id: cid } => assert_eq!(cid, channel_id),
|
||||
_ => panic!("expected NoSignal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use domain;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -47,10 +48,19 @@ pub(super) async fn create_channel(
|
||||
channel.access_password_hash = Some(infra::auth::hash_password(pw));
|
||||
changed = true;
|
||||
}
|
||||
if let Some(url) = payload.webhook_url {
|
||||
channel.webhook_url = Some(url);
|
||||
changed = true;
|
||||
}
|
||||
if let Some(interval) = payload.webhook_poll_interval_secs {
|
||||
channel.webhook_poll_interval_secs = interval;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
channel = state.channel_service.update(channel).await?;
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(domain::DomainEvent::ChannelCreated { channel: channel.clone() });
|
||||
Ok((StatusCode::CREATED, Json(ChannelResponse::from(channel))))
|
||||
}
|
||||
|
||||
@@ -110,9 +120,16 @@ pub(super) async fn update_channel(
|
||||
if let Some(opacity) = payload.logo_opacity {
|
||||
channel.logo_opacity = opacity.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(url) = payload.webhook_url {
|
||||
channel.webhook_url = url;
|
||||
}
|
||||
if let Some(interval) = payload.webhook_poll_interval_secs {
|
||||
channel.webhook_poll_interval_secs = interval;
|
||||
}
|
||||
channel.updated_at = Utc::now();
|
||||
|
||||
let channel = state.channel_service.update(channel).await?;
|
||||
let _ = state.event_tx.send(domain::DomainEvent::ChannelUpdated { channel: channel.clone() });
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
@@ -123,5 +140,6 @@ pub(super) async fn delete_channel(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// ChannelService::delete enforces ownership internally
|
||||
state.channel_service.delete(channel_id, user.id).await?;
|
||||
let _ = state.event_tx.send(domain::DomainEvent::ChannelDeleted { channel_id });
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::DomainError;
|
||||
use domain::{self, DomainError};
|
||||
|
||||
use crate::{
|
||||
dto::ScheduleResponse,
|
||||
@@ -33,6 +33,10 @@ pub(super) async fn generate_schedule(
|
||||
.generate_schedule(channel_id, Utc::now())
|
||||
.await?;
|
||||
|
||||
let _ = state.event_tx.send(domain::DomainEvent::ScheduleGenerated {
|
||||
channel_id,
|
||||
schedule: schedule.clone(),
|
||||
});
|
||||
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,24 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::{ChannelRepository, ScheduleEngineService};
|
||||
use domain::{ChannelRepository, DomainEvent, ScheduleEngineService};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub async fn run_auto_scheduler(
|
||||
schedule_engine: Arc<ScheduleEngineService>,
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
event_tx: broadcast::Sender<DomainEvent>,
|
||||
) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
tick(&schedule_engine, &channel_repo).await;
|
||||
tick(&schedule_engine, &channel_repo, &event_tx).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(
|
||||
schedule_engine: &Arc<ScheduleEngineService>,
|
||||
channel_repo: &Arc<dyn ChannelRepository>,
|
||||
event_tx: &broadcast::Sender<DomainEvent>,
|
||||
) {
|
||||
let channels = match channel_repo.find_auto_schedule_enabled().await {
|
||||
Ok(c) => c,
|
||||
@@ -59,18 +62,25 @@ async fn tick(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = schedule_engine.generate_schedule(channel.id, from).await {
|
||||
tracing::warn!(
|
||||
"auto-scheduler: failed to generate schedule for channel {}: {}",
|
||||
channel.id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"auto-scheduler: generated schedule for channel {} starting at {}",
|
||||
channel.id,
|
||||
from
|
||||
);
|
||||
match schedule_engine.generate_schedule(channel.id, from).await {
|
||||
Ok(schedule) => {
|
||||
tracing::info!(
|
||||
"auto-scheduler: generated schedule for channel {} starting at {}",
|
||||
channel.id,
|
||||
from
|
||||
);
|
||||
let _ = event_tx.send(DomainEvent::ScheduleGenerated {
|
||||
channel_id: channel.id,
|
||||
schedule,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"auto-scheduler: failed to generate schedule for channel {}: {}",
|
||||
channel.id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +231,8 @@ mod tests {
|
||||
Arc::new(MockScheduleRepo { latest: None, saved: saved.clone() });
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
tick(&engine, &channel_repo).await;
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(8);
|
||||
tick(&engine, &channel_repo, &event_tx).await;
|
||||
|
||||
let saved = saved.lock().unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
@@ -240,7 +251,8 @@ mod tests {
|
||||
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
tick(&engine, &channel_repo).await;
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(8);
|
||||
tick(&engine, &channel_repo, &event_tx).await;
|
||||
|
||||
assert_eq!(saved.lock().unwrap().len(), 0);
|
||||
}
|
||||
@@ -256,7 +268,8 @@ mod tests {
|
||||
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
tick(&engine, &channel_repo).await;
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(8);
|
||||
tick(&engine, &channel_repo, &event_tx).await;
|
||||
|
||||
let saved = saved.lock().unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
@@ -274,7 +287,8 @@ mod tests {
|
||||
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
||||
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
||||
|
||||
tick(&engine, &channel_repo).await;
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(8);
|
||||
tick(&engine, &channel_repo, &event_tx).await;
|
||||
|
||||
let saved = saved.lock().unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
|
||||
@@ -11,6 +11,7 @@ use infra::auth::oidc::OidcService;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::events::EventBus;
|
||||
use domain::{ChannelService, ScheduleEngineService, UserService};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -25,6 +26,7 @@ pub struct AppState {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub jwt_validator: Option<Arc<JwtValidator>>,
|
||||
pub config: Arc<Config>,
|
||||
pub event_tx: EventBus,
|
||||
/// Index for the local-files provider, used by the rescan route.
|
||||
#[cfg(feature = "local-files")]
|
||||
pub local_index: Option<Arc<infra::LocalIndex>>,
|
||||
@@ -43,6 +45,7 @@ impl AppState {
|
||||
schedule_engine: ScheduleEngineService,
|
||||
provider_registry: Arc<infra::ProviderRegistry>,
|
||||
config: Config,
|
||||
event_tx: EventBus,
|
||||
) -> anyhow::Result<Self> {
|
||||
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
||||
|
||||
@@ -114,6 +117,7 @@ impl AppState {
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
jwt_validator,
|
||||
config: Arc::new(config),
|
||||
event_tx,
|
||||
#[cfg(feature = "local-files")]
|
||||
local_index: None,
|
||||
#[cfg(feature = "local-files")]
|
||||
|
||||
164
k-tv-backend/api/src/webhook.rs
Normal file
164
k-tv-backend/api/src/webhook.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! WebhookConsumer background task.
|
||||
//!
|
||||
//! Subscribes to the domain-event broadcast channel, looks up each channel's
|
||||
//! webhook_url, and fires HTTP POST requests (fire-and-forget).
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{ChannelRepository, DomainEvent};
|
||||
|
||||
/// Consumes domain events and delivers them to per-channel webhook URLs.
|
||||
///
|
||||
/// Uses fire-and-forget HTTP POST — failures are logged as warnings, never retried.
|
||||
pub async fn run_webhook_consumer(
|
||||
mut rx: broadcast::Receiver<DomainEvent>,
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
client: reqwest::Client,
|
||||
) {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let channel_id = event_channel_id(&event);
|
||||
let payload = build_payload(&event);
|
||||
|
||||
match channel_repo.find_by_id(channel_id).await {
|
||||
Ok(Some(channel)) => {
|
||||
if let Some(url) = channel.webhook_url {
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
post_webhook(&client, &url, payload).await;
|
||||
});
|
||||
}
|
||||
// No webhook_url configured — skip silently
|
||||
}
|
||||
Ok(None) => {
|
||||
// Channel deleted — nothing to do
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("webhook consumer: failed to look up channel {}: {}", channel_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("webhook consumer lagged, {} events dropped", n);
|
||||
// Continue — don't break; catch up from current position
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!("webhook consumer: event bus closed, shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the channel_id from any event variant.
|
||||
fn event_channel_id(event: &DomainEvent) -> Uuid {
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { channel_id, .. } => *channel_id,
|
||||
DomainEvent::NoSignal { channel_id } => *channel_id,
|
||||
DomainEvent::ScheduleGenerated { channel_id, .. } => *channel_id,
|
||||
DomainEvent::ChannelCreated { channel } => channel.id,
|
||||
DomainEvent::ChannelUpdated { channel } => channel.id,
|
||||
DomainEvent::ChannelDeleted { channel_id } => *channel_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the JSON payload for an event.
|
||||
fn build_payload(event: &DomainEvent) -> Value {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
match event {
|
||||
DomainEvent::BroadcastTransition { channel_id, slot } => {
|
||||
let offset_secs = (Utc::now() - slot.start_at).num_seconds().max(0) as u64;
|
||||
json!({
|
||||
"event": "broadcast_transition",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {
|
||||
"slot_id": slot.id,
|
||||
"item": {
|
||||
"id": slot.item.id.as_ref(),
|
||||
"title": slot.item.title,
|
||||
"duration_secs": slot.item.duration_secs,
|
||||
},
|
||||
"start_at": slot.start_at.to_rfc3339(),
|
||||
"end_at": slot.end_at.to_rfc3339(),
|
||||
"offset_secs": offset_secs,
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::NoSignal { channel_id } => {
|
||||
json!({
|
||||
"event": "no_signal",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
DomainEvent::ScheduleGenerated { channel_id, schedule } => {
|
||||
json!({
|
||||
"event": "schedule_generated",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {
|
||||
"generation": schedule.generation,
|
||||
"valid_from": schedule.valid_from.to_rfc3339(),
|
||||
"valid_until": schedule.valid_until.to_rfc3339(),
|
||||
"slot_count": schedule.slots.len(),
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelCreated { channel } => {
|
||||
json!({
|
||||
"event": "channel_created",
|
||||
"timestamp": now,
|
||||
"channel_id": channel.id,
|
||||
"data": {
|
||||
"name": channel.name,
|
||||
"description": channel.description,
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelUpdated { channel } => {
|
||||
json!({
|
||||
"event": "channel_updated",
|
||||
"timestamp": now,
|
||||
"channel_id": channel.id,
|
||||
"data": {
|
||||
"name": channel.name,
|
||||
"description": channel.description,
|
||||
}
|
||||
})
|
||||
}
|
||||
DomainEvent::ChannelDeleted { channel_id } => {
|
||||
json!({
|
||||
"event": "channel_deleted",
|
||||
"timestamp": now,
|
||||
"channel_id": channel_id,
|
||||
"data": {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget HTTP POST to a webhook URL.
|
||||
async fn post_webhook(client: &reqwest::Client, url: &str, payload: Value) {
|
||||
match client.post(url).json(&payload).send().await {
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
warn!(
|
||||
"webhook POST to {} returned status {}",
|
||||
url,
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("webhook POST to {} failed: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user