299 lines
11 KiB
Rust
299 lines
11 KiB
Rust
//! Background auto-scheduler task.
|
|
//!
|
|
//! Runs every hour, finds channels with `auto_schedule = true`, and regenerates
|
|
//! their schedule if it is within 24 hours of expiry (or already expired).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use chrono::Utc;
|
|
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, &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,
|
|
Err(e) => {
|
|
tracing::warn!("auto-scheduler: failed to fetch channels: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let now = Utc::now();
|
|
|
|
for channel in channels {
|
|
let from = match schedule_engine.get_latest_schedule(channel.id).await {
|
|
Ok(Some(s)) => {
|
|
let remaining = s.valid_until - now;
|
|
if remaining > chrono::Duration::hours(24) {
|
|
// Still fresh — skip until it gets close to expiry
|
|
continue;
|
|
} else if s.valid_until > now {
|
|
// Seamless handoff: new schedule starts where the old one ends
|
|
s.valid_until
|
|
} else {
|
|
// Expired: start from now to avoid scheduling in the past
|
|
now
|
|
}
|
|
}
|
|
Ok(None) => now,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"auto-scheduler: failed to fetch latest schedule for channel {}: {}",
|
|
channel.id,
|
|
e
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
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 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>> {
|
|
unimplemented!()
|
|
}
|
|
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
|
|
Ok(self.channels.clone())
|
|
}
|
|
async fn save(&self, _channel: &Channel) -> DomainResult<()> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _id: ChannelId) -> DomainResult<()> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
struct MockScheduleRepo {
|
|
latest: 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(None)
|
|
}
|
|
async fn find_latest(&self, _channel_id: ChannelId) -> DomainResult<Option<GeneratedSchedule>> {
|
|
Ok(self.latest.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() -> Channel {
|
|
let mut ch = Channel::new(Uuid::new_v4(), "Test", "UTC");
|
|
ch.auto_schedule = true;
|
|
ch
|
|
}
|
|
|
|
fn make_schedule(channel_id: ChannelId, valid_until: DateTime<Utc>) -> GeneratedSchedule {
|
|
GeneratedSchedule {
|
|
id: Uuid::new_v4(),
|
|
channel_id,
|
|
valid_from: valid_until - Duration::hours(48),
|
|
valid_until,
|
|
generation: 1,
|
|
slots: vec![],
|
|
}
|
|
}
|
|
|
|
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_no_schedule_generates_from_now() {
|
|
let ch = make_channel();
|
|
let saved = Arc::new(Mutex::new(vec![]));
|
|
let channel_repo: Arc<dyn ChannelRepository> = Arc::new(MockChannelRepo { channels: vec![ch] });
|
|
let schedule_repo: Arc<dyn ScheduleRepository> =
|
|
Arc::new(MockScheduleRepo { latest: None, saved: saved.clone() });
|
|
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
|
|
|
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);
|
|
let diff = (saved[0].valid_from - Utc::now()).num_seconds().abs();
|
|
assert!(diff < 5, "valid_from should be ~now, diff={diff}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fresh_schedule_skips() {
|
|
let ch = make_channel();
|
|
let valid_until = Utc::now() + Duration::hours(25);
|
|
let schedule = make_schedule(ch.id, valid_until);
|
|
let saved = Arc::new(Mutex::new(vec![]));
|
|
let channel_repo: Arc<dyn ChannelRepository> = Arc::new(MockChannelRepo { channels: vec![ch] });
|
|
let schedule_repo: Arc<dyn ScheduleRepository> =
|
|
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
|
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
|
|
|
let (event_tx, _) = tokio::sync::broadcast::channel(8);
|
|
tick(&engine, &channel_repo, &event_tx).await;
|
|
|
|
assert_eq!(saved.lock().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_expiring_schedule_seamless_handoff() {
|
|
let ch = make_channel();
|
|
let valid_until = Utc::now() + Duration::hours(20);
|
|
let schedule = make_schedule(ch.id, valid_until);
|
|
let saved = Arc::new(Mutex::new(vec![]));
|
|
let channel_repo: Arc<dyn ChannelRepository> = Arc::new(MockChannelRepo { channels: vec![ch] });
|
|
let schedule_repo: Arc<dyn ScheduleRepository> =
|
|
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
|
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
|
|
|
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);
|
|
assert_eq!(saved[0].valid_from, valid_until);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_expired_schedule_generates_from_now() {
|
|
let ch = make_channel();
|
|
let valid_until = Utc::now() - Duration::hours(1);
|
|
let schedule = make_schedule(ch.id, valid_until);
|
|
let saved = Arc::new(Mutex::new(vec![]));
|
|
let channel_repo: Arc<dyn ChannelRepository> = Arc::new(MockChannelRepo { channels: vec![ch] });
|
|
let schedule_repo: Arc<dyn ScheduleRepository> =
|
|
Arc::new(MockScheduleRepo { latest: Some(schedule), saved: saved.clone() });
|
|
let engine = make_engine(channel_repo.clone(), schedule_repo);
|
|
|
|
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);
|
|
let diff = (saved[0].valid_from - Utc::now()).num_seconds().abs();
|
|
assert!(diff < 5, "valid_from should be ~now, diff={diff}");
|
|
}
|
|
}
|