diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index 9f94134..ec4d0a5 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -69,6 +69,7 @@ pub struct UpdateChannelRequest { /// Replace the entire schedule config (template import/edit) pub schedule_config: Option, pub recycle_policy: Option, + pub auto_schedule: Option, } #[derive(Debug, Serialize)] @@ -80,6 +81,7 @@ pub struct ChannelResponse { pub timezone: String, pub schedule_config: domain::ScheduleConfig, pub recycle_policy: domain::RecyclePolicy, + pub auto_schedule: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -94,6 +96,7 @@ impl From for ChannelResponse { timezone: c.timezone, schedule_config: c.schedule_config, recycle_policy: c.recycle_policy, + auto_schedule: c.auto_schedule, created_at: c.created_at, updated_at: c.updated_at, } diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index a40f772..e7c2b46 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -21,6 +21,7 @@ mod dto; mod error; mod extractors; mod routes; +mod scheduler; mod state; use crate::config::Config; @@ -72,6 +73,7 @@ async fn main() -> anyhow::Result<()> { // Build media provider — Jellyfin if configured, no-op fallback otherwise. let media_provider: Arc = build_media_provider(&config); + let bg_channel_repo = channel_repo.clone(); let schedule_engine = ScheduleEngineService::new( Arc::clone(&media_provider), channel_repo, @@ -91,6 +93,9 @@ async fn main() -> anyhow::Result<()> { cors_origins: config.cors_allowed_origins.clone(), }; + let bg_schedule_engine = Arc::clone(&state.schedule_engine); + tokio::spawn(scheduler::run_auto_scheduler(bg_schedule_engine, bg_channel_repo)); + let app = Router::new() .nest("/api/v1", routes::api_v1_router()) .with_state(state); diff --git a/k-tv-backend/api/src/routes/channels/crud.rs b/k-tv-backend/api/src/routes/channels/crud.rs index cae991c..754ea4f 100644 --- a/k-tv-backend/api/src/routes/channels/crud.rs +++ b/k-tv-backend/api/src/routes/channels/crud.rs @@ -76,6 +76,9 @@ pub(super) async fn update_channel( if let Some(rp) = payload.recycle_policy { channel.recycle_policy = rp; } + if let Some(auto) = payload.auto_schedule { + channel.auto_schedule = auto; + } channel.updated_at = Utc::now(); let channel = state.channel_service.update(channel).await?; diff --git a/k-tv-backend/api/src/scheduler.rs b/k-tv-backend/api/src/scheduler.rs new file mode 100644 index 0000000..33b04d3 --- /dev/null +++ b/k-tv-backend/api/src/scheduler.rs @@ -0,0 +1,76 @@ +//! 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, ScheduleEngineService}; + +pub async fn run_auto_scheduler( + schedule_engine: Arc, + channel_repo: Arc, +) { + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + tick(&schedule_engine, &channel_repo).await; + } +} + +async fn tick( + schedule_engine: &Arc, + channel_repo: &Arc, +) { + 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; + } + }; + + 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 + ); + } + } +} diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 85362e5..680c9fd 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -81,6 +81,7 @@ pub struct Channel { pub timezone: String, pub schedule_config: ScheduleConfig, pub recycle_policy: RecyclePolicy, + pub auto_schedule: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -100,6 +101,7 @@ impl Channel { timezone: timezone.into(), schedule_config: ScheduleConfig::default(), recycle_policy: RecyclePolicy::default(), + auto_schedule: false, created_at: now, updated_at: now, } diff --git a/k-tv-backend/domain/src/repositories.rs b/k-tv-backend/domain/src/repositories.rs index 98f44f1..d2dd07b 100644 --- a/k-tv-backend/domain/src/repositories.rs +++ b/k-tv-backend/domain/src/repositories.rs @@ -37,6 +37,7 @@ pub trait ChannelRepository: Send + Sync { async fn find_by_id(&self, id: ChannelId) -> DomainResult>; async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; async fn find_all(&self) -> DomainResult>; + async fn find_auto_schedule_enabled(&self) -> DomainResult>; /// Insert or update a channel. async fn save(&self, channel: &Channel) -> DomainResult<()>; async fn delete(&self, id: ChannelId) -> DomainResult<()>; diff --git a/k-tv-backend/domain/src/services/schedule/mod.rs b/k-tv-backend/domain/src/services/schedule/mod.rs index a56e725..e224b38 100644 --- a/k-tv-backend/domain/src/services/schedule/mod.rs +++ b/k-tv-backend/domain/src/services/schedule/mod.rs @@ -206,6 +206,14 @@ impl ScheduleEngineService { }) } + /// Return the most recently generated schedule for a channel (used by the background scheduler). + pub async fn get_latest_schedule( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + self.schedule_repo.find_latest(channel_id).await + } + /// Look up the schedule currently active at `at` without generating a new one. pub async fn get_active_schedule( &self, diff --git a/k-tv-backend/infra/src/channel_repository/mapping.rs b/k-tv-backend/infra/src/channel_repository/mapping.rs index b1410a0..8b64284 100644 --- a/k-tv-backend/infra/src/channel_repository/mapping.rs +++ b/k-tv-backend/infra/src/channel_repository/mapping.rs @@ -13,6 +13,7 @@ pub(super) struct ChannelRow { pub timezone: String, pub schedule_config: String, pub recycle_policy: String, + pub auto_schedule: i64, pub created_at: String, pub updated_at: String, } @@ -51,6 +52,7 @@ impl TryFrom for Channel { timezone: row.timezone, schedule_config, recycle_policy, + auto_schedule: row.auto_schedule != 0, created_at: parse_dt(&row.created_at)?, updated_at: parse_dt(&row.updated_at)?, }) @@ -58,4 +60,4 @@ impl TryFrom for Channel { } pub(super) const SELECT_COLS: &str = - "id, owner_id, name, description, timezone, schedule_config, recycle_policy, created_at, updated_at"; + "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at"; diff --git a/k-tv-backend/infra/src/channel_repository/postgres.rs b/k-tv-backend/infra/src/channel_repository/postgres.rs index a28fa1e..776553c 100644 --- a/k-tv-backend/infra/src/channel_repository/postgres.rs +++ b/k-tv-backend/infra/src/channel_repository/postgres.rs @@ -61,14 +61,15 @@ impl ChannelRepository for PostgresChannelRepository { sqlx::query( r#" INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, timezone = EXCLUDED.timezone, schedule_config = EXCLUDED.schedule_config, recycle_policy = EXCLUDED.recycle_policy, + auto_schedule = EXCLUDED.auto_schedule, updated_at = EXCLUDED.updated_at "#, ) @@ -79,6 +80,7 @@ impl ChannelRepository for PostgresChannelRepository { .bind(&channel.timezone) .bind(&schedule_config) .bind(&recycle_policy) + .bind(channel.auto_schedule as i64) .bind(channel.created_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339()) .execute(&self.pool) @@ -88,6 +90,18 @@ impl ChannelRepository for PostgresChannelRepository { Ok(()) } + async fn find_auto_schedule_enabled(&self) -> DomainResult> { + let sql = format!( + "SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = 1 ORDER BY created_at ASC" + ); + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + + rows.into_iter().map(Channel::try_from).collect() + } + async fn delete(&self, id: ChannelId) -> DomainResult<()> { sqlx::query("DELETE FROM channels WHERE id = $1") .bind(id.to_string()) diff --git a/k-tv-backend/infra/src/channel_repository/sqlite.rs b/k-tv-backend/infra/src/channel_repository/sqlite.rs index f6e5398..8fa291f 100644 --- a/k-tv-backend/infra/src/channel_repository/sqlite.rs +++ b/k-tv-backend/infra/src/channel_repository/sqlite.rs @@ -61,14 +61,15 @@ impl ChannelRepository for SqliteChannelRepository { sqlx::query( r#" INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, timezone = excluded.timezone, schedule_config = excluded.schedule_config, recycle_policy = excluded.recycle_policy, + auto_schedule = excluded.auto_schedule, updated_at = excluded.updated_at "#, ) @@ -79,6 +80,7 @@ impl ChannelRepository for SqliteChannelRepository { .bind(&channel.timezone) .bind(&schedule_config) .bind(&recycle_policy) + .bind(channel.auto_schedule as i64) .bind(channel.created_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339()) .execute(&self.pool) @@ -88,6 +90,18 @@ impl ChannelRepository for SqliteChannelRepository { Ok(()) } + async fn find_auto_schedule_enabled(&self) -> DomainResult> { + let sql = format!( + "SELECT {SELECT_COLS} FROM channels WHERE auto_schedule = 1 ORDER BY created_at ASC" + ); + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + + rows.into_iter().map(Channel::try_from).collect() + } + async fn delete(&self, id: ChannelId) -> DomainResult<()> { sqlx::query("DELETE FROM channels WHERE id = ?") .bind(id.to_string()) diff --git a/k-tv-backend/migrations_sqlite/20240103000000_add_auto_schedule_to_channels.sql b/k-tv-backend/migrations_sqlite/20240103000000_add_auto_schedule_to_channels.sql new file mode 100644 index 0000000..8e3db78 --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20240103000000_add_auto_schedule_to_channels.sql @@ -0,0 +1 @@ +ALTER TABLE channels ADD COLUMN auto_schedule INTEGER NOT NULL DEFAULT 0; diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index cbeb864..88ac46f 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -72,6 +72,7 @@ const channelFormSchema = z.object({ cooldown_generations: z.number().int().min(0).nullable().optional(), min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"), }), + auto_schedule: z.boolean(), }); type FieldErrors = Record; @@ -676,6 +677,7 @@ interface EditChannelSheetProps { timezone: string; schedule_config: { blocks: ProgrammingBlock[] }; recycle_policy: RecyclePolicy; + auto_schedule: boolean; }, ) => void; isPending: boolean; @@ -699,6 +701,7 @@ export function EditChannelSheet({ cooldown_generations: null, min_available_ratio: 0.1, }); + const [autoSchedule, setAutoSchedule] = useState(false); const [selectedBlockId, setSelectedBlockId] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); @@ -709,6 +712,7 @@ export function EditChannelSheet({ setTimezone(channel.timezone); setBlocks(channel.schedule_config.blocks); setRecyclePolicy(channel.recycle_policy); + setAutoSchedule(channel.auto_schedule); setSelectedBlockId(null); setFieldErrors({}); } @@ -719,7 +723,7 @@ export function EditChannelSheet({ if (!channel) return; const result = channelFormSchema.safeParse({ - name, description, timezone, blocks, recycle_policy: recyclePolicy, + name, description, timezone, blocks, recycle_policy: recyclePolicy, auto_schedule: autoSchedule, }); if (!result.success) { @@ -734,6 +738,7 @@ export function EditChannelSheet({ timezone, schedule_config: { blocks }, recycle_policy: recyclePolicy, + auto_schedule: autoSchedule, }); }; @@ -799,6 +804,24 @@ export function EditChannelSheet({ className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" /> + + {/* Programming blocks */} diff --git a/k-tv-frontend/app/(main)/dashboard/page.tsx b/k-tv-frontend/app/(main)/dashboard/page.tsx index bd08af4..57825e3 100644 --- a/k-tv-frontend/app/(main)/dashboard/page.tsx +++ b/k-tv-frontend/app/(main)/dashboard/page.tsx @@ -122,6 +122,7 @@ export default function DashboardPage() { timezone: string; schedule_config: { blocks: ProgrammingBlock[] }; recycle_policy: RecyclePolicy; + auto_schedule: boolean; }, ) => { updateChannel.mutate( diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index edc8d68..1e82f25 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -103,6 +103,7 @@ export interface ChannelResponse { timezone: string; schedule_config: ScheduleConfig; recycle_policy: RecyclePolicy; + auto_schedule: boolean; created_at: string; updated_at: string; } @@ -119,6 +120,7 @@ export interface UpdateChannelRequest { timezone?: string; schedule_config?: ScheduleConfig; recycle_policy?: RecyclePolicy; + auto_schedule?: boolean; } // Media & Schedule