//! 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, channel_repo: Arc, event_tx: broadcast::Sender, ) { loop { tokio::time::sleep(Duration::from_secs(3600)).await; tick(&schedule_engine, &channel_repo, &event_tx).await; } } async fn tick( schedule_engine: &Arc, channel_repo: &Arc, event_tx: &broadcast::Sender, ) { 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, } #[async_trait] impl ChannelRepository for MockChannelRepo { async fn find_by_id(&self, id: ChannelId) -> DomainResult> { Ok(self.channels.iter().find(|c| c.id == id).cloned()) } async fn find_by_owner(&self, _owner_id: UserId) -> DomainResult> { unimplemented!() } async fn find_all(&self) -> DomainResult> { unimplemented!() } async fn find_auto_schedule_enabled(&self) -> DomainResult> { Ok(self.channels.clone()) } async fn save(&self, _channel: &Channel) -> DomainResult<()> { unimplemented!() } async fn delete(&self, _id: ChannelId) -> DomainResult<()> { unimplemented!() } } struct MockScheduleRepo { latest: Option, saved: Arc>>, } #[async_trait] impl ScheduleRepository for MockScheduleRepo { async fn find_active( &self, _channel_id: ChannelId, _at: DateTime, ) -> DomainResult> { Ok(None) } async fn find_latest(&self, _channel_id: ChannelId) -> DomainResult> { 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> { 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> { Ok(vec![]) } async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult> { Ok(None) } async fn get_stream_url(&self, _item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult { unimplemented!() } fn provider_ids(&self) -> Vec { vec![] } fn primary_id(&self) -> &str { "" } fn capabilities(&self, _provider_id: &str) -> Option { None } async fn list_collections(&self, _provider_id: &str) -> DomainResult> { unimplemented!() } async fn list_series(&self, _provider_id: &str, _collection_id: Option<&str>) -> DomainResult> { unimplemented!() } async fn list_genres(&self, _provider_id: &str, _content_type: Option<&ContentType>) -> DomainResult> { 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) -> 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, schedule_repo: Arc, ) -> Arc { 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 = Arc::new(MockChannelRepo { channels: vec![ch] }); let schedule_repo: Arc = 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 = Arc::new(MockChannelRepo { channels: vec![ch] }); let schedule_repo: Arc = 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 = Arc::new(MockChannelRepo { channels: vec![ch] }); let schedule_repo: Arc = 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 = Arc::new(MockChannelRepo { channels: vec![ch] }); let schedule_repo: Arc = 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}"); } }