From 466d34b5d0e3a9e92a9ca8035d7f7f92e34cf5a9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 02:14:07 +0200 Subject: [PATCH] application: config_snapshots, admin, providers, iptv --- crates/application/src/admin/activity_log.rs | 13 +++ crates/application/src/admin/commands.rs | 4 + crates/application/src/admin/deps.rs | 9 ++ crates/application/src/admin/get_settings.rs | 16 ++++ crates/application/src/admin/mod.rs | 10 +++ crates/application/src/admin/queries.rs | 7 ++ .../src/admin/tests/get_settings.rs | 46 ++++++++++ .../src/admin/tests/update_settings.rs | 63 ++++++++++++++ .../application/src/admin/update_settings.rs | 18 ++++ .../src/config_snapshots/commands.rs | 20 +++++ .../application/src/config_snapshots/deps.rs | 9 ++ .../application/src/config_snapshots/get.rs | 17 ++++ .../application/src/config_snapshots/list.rs | 19 +++++ .../application/src/config_snapshots/mod.rs | 12 +++ .../src/config_snapshots/patch_label.rs | 18 ++++ .../src/config_snapshots/queries.rs | 12 +++ .../src/config_snapshots/restore.rs | 43 ++++++++++ .../application/src/config_snapshots/save.rs | 30 +++++++ .../src/config_snapshots/tests/list.rs | 77 +++++++++++++++++ .../src/config_snapshots/tests/save.rs | 75 +++++++++++++++++ crates/application/src/iptv/deps.rs | 9 ++ crates/application/src/iptv/m3u.rs | 18 ++++ crates/application/src/iptv/mod.rs | 7 ++ crates/application/src/iptv/queries.rs | 8 ++ crates/application/src/iptv/tests/m3u.rs | 84 +++++++++++++++++++ crates/application/src/iptv/xmltv.rs | 27 ++++++ crates/application/src/lib.rs | 4 + crates/application/src/providers/commands.rs | 12 +++ crates/application/src/providers/delete.rs | 9 ++ crates/application/src/providers/deps.rs | 9 ++ crates/application/src/providers/get.rs | 13 +++ crates/application/src/providers/list.rs | 17 ++++ crates/application/src/providers/mod.rs | 11 +++ crates/application/src/providers/queries.rs | 7 ++ .../application/src/providers/tests/list.rs | 45 ++++++++++ .../application/src/providers/tests/upsert.rs | 76 +++++++++++++++++ crates/application/src/providers/upsert.rs | 21 +++++ 37 files changed, 895 insertions(+) create mode 100644 crates/application/src/admin/activity_log.rs create mode 100644 crates/application/src/admin/commands.rs create mode 100644 crates/application/src/admin/deps.rs create mode 100644 crates/application/src/admin/get_settings.rs create mode 100644 crates/application/src/admin/mod.rs create mode 100644 crates/application/src/admin/queries.rs create mode 100644 crates/application/src/admin/tests/get_settings.rs create mode 100644 crates/application/src/admin/tests/update_settings.rs create mode 100644 crates/application/src/admin/update_settings.rs create mode 100644 crates/application/src/config_snapshots/commands.rs create mode 100644 crates/application/src/config_snapshots/deps.rs create mode 100644 crates/application/src/config_snapshots/get.rs create mode 100644 crates/application/src/config_snapshots/list.rs create mode 100644 crates/application/src/config_snapshots/mod.rs create mode 100644 crates/application/src/config_snapshots/patch_label.rs create mode 100644 crates/application/src/config_snapshots/queries.rs create mode 100644 crates/application/src/config_snapshots/restore.rs create mode 100644 crates/application/src/config_snapshots/save.rs create mode 100644 crates/application/src/config_snapshots/tests/list.rs create mode 100644 crates/application/src/config_snapshots/tests/save.rs create mode 100644 crates/application/src/iptv/deps.rs create mode 100644 crates/application/src/iptv/m3u.rs create mode 100644 crates/application/src/iptv/mod.rs create mode 100644 crates/application/src/iptv/queries.rs create mode 100644 crates/application/src/iptv/tests/m3u.rs create mode 100644 crates/application/src/iptv/xmltv.rs create mode 100644 crates/application/src/providers/commands.rs create mode 100644 crates/application/src/providers/delete.rs create mode 100644 crates/application/src/providers/deps.rs create mode 100644 crates/application/src/providers/get.rs create mode 100644 crates/application/src/providers/list.rs create mode 100644 crates/application/src/providers/mod.rs create mode 100644 crates/application/src/providers/queries.rs create mode 100644 crates/application/src/providers/tests/list.rs create mode 100644 crates/application/src/providers/tests/upsert.rs create mode 100644 crates/application/src/providers/upsert.rs diff --git a/crates/application/src/admin/activity_log.rs b/crates/application/src/admin/activity_log.rs new file mode 100644 index 0000000..d0efd2b --- /dev/null +++ b/crates/application/src/admin/activity_log.rs @@ -0,0 +1,13 @@ +use domain::models::ActivityEvent; +use domain::DomainResult; + +use super::deps::AdminDeps; +use super::queries::GetActivityLogQuery; + +/// Get recent activity log entries. +pub async fn execute( + deps: &AdminDeps, + query: GetActivityLogQuery, +) -> DomainResult> { + deps.activity_query.recent(query.limit).await +} diff --git a/crates/application/src/admin/commands.rs b/crates/application/src/admin/commands.rs new file mode 100644 index 0000000..cbe9cd8 --- /dev/null +++ b/crates/application/src/admin/commands.rs @@ -0,0 +1,4 @@ +/// Update one or more admin settings (key-value pairs). +pub struct UpdateSettingsCommand { + pub settings: Vec<(String, String)>, +} diff --git a/crates/application/src/admin/deps.rs b/crates/application/src/admin/deps.rs new file mode 100644 index 0000000..9ab55cc --- /dev/null +++ b/crates/application/src/admin/deps.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use domain::ports::{ActivityLogQuery, AppSettingsRepository}; + +/// Dependencies for admin use cases. +pub struct AdminDeps { + pub settings_repo: Arc, + pub activity_query: Arc, +} diff --git a/crates/application/src/admin/get_settings.rs b/crates/application/src/admin/get_settings.rs new file mode 100644 index 0000000..11f20f7 --- /dev/null +++ b/crates/application/src/admin/get_settings.rs @@ -0,0 +1,16 @@ +use domain::DomainResult; + +use super::deps::AdminDeps; +use super::queries::GetSettingsQuery; + +/// Get all admin settings as key-value pairs. +pub async fn execute( + deps: &AdminDeps, + _query: GetSettingsQuery, +) -> DomainResult> { + deps.settings_repo.get_all().await +} + +#[cfg(test)] +#[path = "tests/get_settings.rs"] +mod tests; diff --git a/crates/application/src/admin/mod.rs b/crates/application/src/admin/mod.rs new file mode 100644 index 0000000..a94b6f7 --- /dev/null +++ b/crates/application/src/admin/mod.rs @@ -0,0 +1,10 @@ +pub mod activity_log; +pub mod commands; +pub mod deps; +pub mod get_settings; +pub mod queries; +pub mod update_settings; + +pub use commands::UpdateSettingsCommand; +pub use deps::AdminDeps; +pub use queries::{GetActivityLogQuery, GetSettingsQuery}; diff --git a/crates/application/src/admin/queries.rs b/crates/application/src/admin/queries.rs new file mode 100644 index 0000000..d2b4d9e --- /dev/null +++ b/crates/application/src/admin/queries.rs @@ -0,0 +1,7 @@ +/// Get all admin settings. +pub struct GetSettingsQuery; + +/// Get recent activity log entries. +pub struct GetActivityLogQuery { + pub limit: u32, +} diff --git a/crates/application/src/admin/tests/get_settings.rs b/crates/application/src/admin/tests/get_settings.rs new file mode 100644 index 0000000..1f509a1 --- /dev/null +++ b/crates/application/src/admin/tests/get_settings.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryActivityLog, InMemoryAppSettings}; + +use crate::admin::commands::UpdateSettingsCommand; +use crate::admin::deps::AdminDeps; +use crate::admin::queries::GetSettingsQuery; +use crate::admin::{get_settings, update_settings}; + +fn make_deps() -> AdminDeps { + AdminDeps { + settings_repo: Arc::new(InMemoryAppSettings::new()), + activity_query: Arc::new(InMemoryActivityLog::new()), + } +} + +#[tokio::test] +async fn get_empty_settings() { + let deps = make_deps(); + let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap(); + assert!(settings.is_empty()); +} + +#[tokio::test] +async fn get_returns_stored_settings() { + let deps = make_deps(); + + update_settings::execute( + &deps, + UpdateSettingsCommand { + settings: vec![ + ("a".into(), "1".into()), + ("b".into(), "2".into()), + ], + }, + ) + .await + .unwrap(); + + let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap(); + assert_eq!(settings.len(), 2); + + let keys: Vec<&str> = settings.iter().map(|(k, _)| k.as_str()).collect(); + assert!(keys.contains(&"a")); + assert!(keys.contains(&"b")); +} diff --git a/crates/application/src/admin/tests/update_settings.rs b/crates/application/src/admin/tests/update_settings.rs new file mode 100644 index 0000000..32d23d9 --- /dev/null +++ b/crates/application/src/admin/tests/update_settings.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryActivityLog, InMemoryAppSettings}; + +use crate::admin::commands::UpdateSettingsCommand; +use crate::admin::deps::AdminDeps; +use crate::admin::update_settings; + +fn make_deps() -> AdminDeps { + AdminDeps { + settings_repo: Arc::new(InMemoryAppSettings::new()), + activity_query: Arc::new(InMemoryActivityLog::new()), + } +} + +#[tokio::test] +async fn update_stores_settings() { + let deps = make_deps(); + + update_settings::execute( + &deps, + UpdateSettingsCommand { + settings: vec![ + ("library_sync_interval_hours".into(), "12".into()), + ("theme".into(), "dark".into()), + ], + }, + ) + .await + .unwrap(); + + let val = deps.settings_repo.get("library_sync_interval_hours").await.unwrap(); + assert_eq!(val, Some("12".into())); + + let val2 = deps.settings_repo.get("theme").await.unwrap(); + assert_eq!(val2, Some("dark".into())); +} + +#[tokio::test] +async fn update_overwrites_existing() { + let deps = make_deps(); + + update_settings::execute( + &deps, + UpdateSettingsCommand { + settings: vec![("key".into(), "old".into())], + }, + ) + .await + .unwrap(); + + update_settings::execute( + &deps, + UpdateSettingsCommand { + settings: vec![("key".into(), "new".into())], + }, + ) + .await + .unwrap(); + + let val = deps.settings_repo.get("key").await.unwrap(); + assert_eq!(val, Some("new".into())); +} diff --git a/crates/application/src/admin/update_settings.rs b/crates/application/src/admin/update_settings.rs new file mode 100644 index 0000000..4b77605 --- /dev/null +++ b/crates/application/src/admin/update_settings.rs @@ -0,0 +1,18 @@ +use domain::DomainResult; + +use super::commands::UpdateSettingsCommand; +use super::deps::AdminDeps; + +/// Update one or more admin settings. +/// +/// Iterates key/value pairs and upserts each one. +pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<()> { + for (key, value) in &cmd.settings { + deps.settings_repo.set(key, value).await?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "tests/update_settings.rs"] +mod tests; diff --git a/crates/application/src/config_snapshots/commands.rs b/crates/application/src/config_snapshots/commands.rs new file mode 100644 index 0000000..72d70a3 --- /dev/null +++ b/crates/application/src/config_snapshots/commands.rs @@ -0,0 +1,20 @@ +use uuid::Uuid; + +/// Save a snapshot of the channel's current config. +pub struct SaveSnapshotCommand { + pub channel_id: Uuid, + pub label: Option, +} + +/// Update the label on an existing snapshot. +pub struct PatchLabelCommand { + pub channel_id: Uuid, + pub snapshot_id: Uuid, + pub label: Option, +} + +/// Restore a channel's config from a snapshot. +pub struct RestoreSnapshotCommand { + pub channel_id: Uuid, + pub snapshot_id: Uuid, +} diff --git a/crates/application/src/config_snapshots/deps.rs b/crates/application/src/config_snapshots/deps.rs new file mode 100644 index 0000000..3e8fc1c --- /dev/null +++ b/crates/application/src/config_snapshots/deps.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use domain::ports::{ChannelCommand, ChannelQuery}; + +/// Dependencies for config snapshot use cases. +pub struct ConfigSnapshotDeps { + pub channel_command: Arc, + pub channel_query: Arc, +} diff --git a/crates/application/src/config_snapshots/get.rs b/crates/application/src/config_snapshots/get.rs new file mode 100644 index 0000000..2b7e4ba --- /dev/null +++ b/crates/application/src/config_snapshots/get.rs @@ -0,0 +1,17 @@ +use domain::models::ChannelConfigSnapshot; +use domain::value_objects::ChannelId; +use domain::DomainResult; + +use super::deps::ConfigSnapshotDeps; +use super::queries::GetSnapshotQuery; + +/// Get a specific config snapshot by channel and snapshot ID. +pub async fn execute( + deps: &ConfigSnapshotDeps, + query: GetSnapshotQuery, +) -> DomainResult> { + let channel_id = ChannelId::from(query.channel_id); + deps.channel_query + .get_config_snapshot(channel_id, query.snapshot_id) + .await +} diff --git a/crates/application/src/config_snapshots/list.rs b/crates/application/src/config_snapshots/list.rs new file mode 100644 index 0000000..3693ba0 --- /dev/null +++ b/crates/application/src/config_snapshots/list.rs @@ -0,0 +1,19 @@ +use domain::models::ChannelConfigSnapshot; +use domain::value_objects::ChannelId; +use domain::DomainResult; + +use super::deps::ConfigSnapshotDeps; +use super::queries::ListSnapshotsQuery; + +/// List all config snapshots for a channel, newest first. +pub async fn execute( + deps: &ConfigSnapshotDeps, + query: ListSnapshotsQuery, +) -> DomainResult> { + let channel_id = ChannelId::from(query.channel_id); + deps.channel_query.list_config_snapshots(channel_id).await +} + +#[cfg(test)] +#[path = "tests/list.rs"] +mod tests; diff --git a/crates/application/src/config_snapshots/mod.rs b/crates/application/src/config_snapshots/mod.rs new file mode 100644 index 0000000..9a90607 --- /dev/null +++ b/crates/application/src/config_snapshots/mod.rs @@ -0,0 +1,12 @@ +pub mod commands; +pub mod deps; +pub mod get; +pub mod list; +pub mod patch_label; +pub mod queries; +pub mod restore; +pub mod save; + +pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand}; +pub use deps::ConfigSnapshotDeps; +pub use queries::{GetSnapshotQuery, ListSnapshotsQuery}; diff --git a/crates/application/src/config_snapshots/patch_label.rs b/crates/application/src/config_snapshots/patch_label.rs new file mode 100644 index 0000000..f7a2a4e --- /dev/null +++ b/crates/application/src/config_snapshots/patch_label.rs @@ -0,0 +1,18 @@ +use domain::models::ChannelConfigSnapshot; +use domain::value_objects::ChannelId; +use domain::DomainResult; + +use super::commands::PatchLabelCommand; +use super::deps::ConfigSnapshotDeps; + +/// Update the label on an existing config snapshot. +pub async fn execute( + deps: &ConfigSnapshotDeps, + cmd: PatchLabelCommand, +) -> DomainResult> { + let channel_id = ChannelId::from(cmd.channel_id); + + deps.channel_command + .patch_config_snapshot_label(channel_id, cmd.snapshot_id, cmd.label) + .await +} diff --git a/crates/application/src/config_snapshots/queries.rs b/crates/application/src/config_snapshots/queries.rs new file mode 100644 index 0000000..cd26185 --- /dev/null +++ b/crates/application/src/config_snapshots/queries.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +/// List all config snapshots for a channel (newest first). +pub struct ListSnapshotsQuery { + pub channel_id: Uuid, +} + +/// Get a specific config snapshot. +pub struct GetSnapshotQuery { + pub channel_id: Uuid, + pub snapshot_id: Uuid, +} diff --git a/crates/application/src/config_snapshots/restore.rs b/crates/application/src/config_snapshots/restore.rs new file mode 100644 index 0000000..c495081 --- /dev/null +++ b/crates/application/src/config_snapshots/restore.rs @@ -0,0 +1,43 @@ +use domain::models::Channel; +use domain::value_objects::ChannelId; +use domain::{DomainError, DomainResult}; + +use super::commands::RestoreSnapshotCommand; +use super::deps::ConfigSnapshotDeps; + +/// Restore a channel's config from a snapshot. +/// +/// Flow: find snapshot -> find channel -> snapshot current config (backup) -> +/// apply snapshot config to channel -> save channel -> return updated channel. +pub async fn execute( + deps: &ConfigSnapshotDeps, + cmd: RestoreSnapshotCommand, +) -> DomainResult { + let channel_id = ChannelId::from(cmd.channel_id); + + let snapshot = deps + .channel_query + .get_config_snapshot(channel_id, cmd.snapshot_id) + .await? + .ok_or(DomainError::ValidationError(format!( + "Snapshot {} not found", + cmd.snapshot_id + )))?; + + let mut channel = deps + .channel_query + .find_by_id(channel_id) + .await? + .ok_or(DomainError::ChannelNotFound(cmd.channel_id))?; + + // Auto-snapshot the current config before overwriting + deps.channel_command + .save_config_snapshot(channel_id, channel.schedule_config(), None) + .await?; + + // Apply the snapshot's config + channel.set_schedule_config(snapshot.config().clone()); + deps.channel_command.save(&channel).await?; + + Ok(channel) +} diff --git a/crates/application/src/config_snapshots/save.rs b/crates/application/src/config_snapshots/save.rs new file mode 100644 index 0000000..d6654bf --- /dev/null +++ b/crates/application/src/config_snapshots/save.rs @@ -0,0 +1,30 @@ +use domain::models::ChannelConfigSnapshot; +use domain::value_objects::ChannelId; +use domain::{DomainError, DomainResult}; + +use super::commands::SaveSnapshotCommand; +use super::deps::ConfigSnapshotDeps; + +/// Save a snapshot of the channel's current schedule config. +/// +/// Flow: find channel -> snapshot its current config -> return snapshot. +pub async fn execute( + deps: &ConfigSnapshotDeps, + cmd: SaveSnapshotCommand, +) -> DomainResult { + let channel_id = ChannelId::from(cmd.channel_id); + + let channel = deps + .channel_query + .find_by_id(channel_id) + .await? + .ok_or(DomainError::ChannelNotFound(cmd.channel_id))?; + + deps.channel_command + .save_config_snapshot(channel_id, channel.schedule_config(), cmd.label) + .await +} + +#[cfg(test)] +#[path = "tests/save.rs"] +mod tests; diff --git a/crates/application/src/config_snapshots/tests/list.rs b/crates/application/src/config_snapshots/tests/list.rs new file mode 100644 index 0000000..b5a7614 --- /dev/null +++ b/crates/application/src/config_snapshots/tests/list.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use domain::models::Channel; +use domain::testing::InMemoryChannelRepository; +use domain::value_objects::UserId; + +use crate::config_snapshots::commands::SaveSnapshotCommand; +use crate::config_snapshots::deps::ConfigSnapshotDeps; +use crate::config_snapshots::queries::ListSnapshotsQuery; +use crate::config_snapshots::{list, save}; + +fn make_deps() -> (ConfigSnapshotDeps, Arc) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let deps = ConfigSnapshotDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + }; + (deps, repo) +} + +async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel { + let channel = Channel::new(UserId::generate(), "Test Channel", "UTC"); + repo.channels + .lock() + .unwrap() + .insert(channel.id().value(), channel.clone()); + channel +} + +#[tokio::test] +async fn list_empty() { + let (deps, repo) = make_deps(); + let channel = seed_channel(&repo).await; + + let snaps = list::execute( + &deps, + ListSnapshotsQuery { + channel_id: channel.id().value(), + }, + ) + .await + .unwrap(); + + assert!(snaps.is_empty()); +} + +#[tokio::test] +async fn list_returns_saved_snapshots() { + let (deps, repo) = make_deps(); + let channel = seed_channel(&repo).await; + + for label in ["first", "second"] { + save::execute( + &deps, + SaveSnapshotCommand { + channel_id: channel.id().value(), + label: Some(label.into()), + }, + ) + .await + .unwrap(); + } + + let snaps = list::execute( + &deps, + ListSnapshotsQuery { + channel_id: channel.id().value(), + }, + ) + .await + .unwrap(); + + assert_eq!(snaps.len(), 2); + // Newest first + assert_eq!(snaps[0].version_num(), 2); + assert_eq!(snaps[1].version_num(), 1); +} diff --git a/crates/application/src/config_snapshots/tests/save.rs b/crates/application/src/config_snapshots/tests/save.rs new file mode 100644 index 0000000..1f8e71c --- /dev/null +++ b/crates/application/src/config_snapshots/tests/save.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use domain::models::Channel; +use domain::testing::InMemoryChannelRepository; +use domain::value_objects::UserId; + +use crate::config_snapshots::commands::SaveSnapshotCommand; +use crate::config_snapshots::deps::ConfigSnapshotDeps; +use crate::config_snapshots::save; + +fn make_deps() -> (ConfigSnapshotDeps, Arc) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let deps = ConfigSnapshotDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + }; + (deps, repo) +} + +async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel { + let channel = Channel::new(UserId::generate(), "Test Channel", "UTC"); + repo.channels + .lock() + .unwrap() + .insert(channel.id().value(), channel.clone()); + channel +} + +#[tokio::test] +async fn save_creates_snapshot() { + let (deps, repo) = make_deps(); + let channel = seed_channel(&repo).await; + + let snap = save::execute( + &deps, + SaveSnapshotCommand { + channel_id: channel.id().value(), + label: Some("v1".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(snap.channel_id(), channel.id()); + assert_eq!(snap.label(), Some("v1")); + assert_eq!(snap.version_num(), 1); +} + +#[tokio::test] +async fn save_increments_version() { + let (deps, repo) = make_deps(); + let channel = seed_channel(&repo).await; + + save::execute( + &deps, + SaveSnapshotCommand { + channel_id: channel.id().value(), + label: None, + }, + ) + .await + .unwrap(); + + let snap2 = save::execute( + &deps, + SaveSnapshotCommand { + channel_id: channel.id().value(), + label: None, + }, + ) + .await + .unwrap(); + + assert_eq!(snap2.version_num(), 2); +} diff --git a/crates/application/src/iptv/deps.rs b/crates/application/src/iptv/deps.rs new file mode 100644 index 0000000..900503b --- /dev/null +++ b/crates/application/src/iptv/deps.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use domain::ports::{ChannelQuery, ScheduleQuery}; + +/// Dependencies for IPTV export use cases. +pub struct IptvDeps { + pub channel_query: Arc, + pub schedule_query: Arc, +} diff --git a/crates/application/src/iptv/m3u.rs b/crates/application/src/iptv/m3u.rs new file mode 100644 index 0000000..3832b56 --- /dev/null +++ b/crates/application/src/iptv/m3u.rs @@ -0,0 +1,18 @@ +use domain::services::iptv::generate_m3u; +use domain::DomainResult; + +use super::deps::IptvDeps; +use super::queries::GetM3uQuery; + +/// Generate an M3U playlist for all channels. +/// +/// Flow: fetch all channels -> delegate to domain::generate_m3u -> return string. +pub async fn execute(deps: &IptvDeps, query: GetM3uQuery) -> DomainResult { + let channels = deps.channel_query.find_all().await?; + let token = query.token.as_deref().unwrap_or(""); + Ok(generate_m3u(&channels, &query.base_url, token)) +} + +#[cfg(test)] +#[path = "tests/m3u.rs"] +mod tests; diff --git a/crates/application/src/iptv/mod.rs b/crates/application/src/iptv/mod.rs new file mode 100644 index 0000000..e516c98 --- /dev/null +++ b/crates/application/src/iptv/mod.rs @@ -0,0 +1,7 @@ +pub mod deps; +pub mod m3u; +pub mod queries; +pub mod xmltv; + +pub use deps::IptvDeps; +pub use queries::{GetM3uQuery, GetXmltvQuery}; diff --git a/crates/application/src/iptv/queries.rs b/crates/application/src/iptv/queries.rs new file mode 100644 index 0000000..7c550d8 --- /dev/null +++ b/crates/application/src/iptv/queries.rs @@ -0,0 +1,8 @@ +/// Generate an M3U playlist for all channels. +pub struct GetM3uQuery { + pub base_url: String, + pub token: Option, +} + +/// Generate an XMLTV EPG document for all channels. +pub struct GetXmltvQuery; diff --git a/crates/application/src/iptv/tests/m3u.rs b/crates/application/src/iptv/tests/m3u.rs new file mode 100644 index 0000000..2adc350 --- /dev/null +++ b/crates/application/src/iptv/tests/m3u.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use domain::models::Channel; +use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository}; +use domain::value_objects::UserId; + +use crate::iptv::deps::IptvDeps; +use crate::iptv::m3u; +use crate::iptv::queries::GetM3uQuery; + +fn make_deps() -> (IptvDeps, Arc) { + let channel_repo = Arc::new(InMemoryChannelRepository::new()); + let schedule_repo = Arc::new(InMemoryScheduleRepository::new()); + let deps = IptvDeps { + channel_query: channel_repo.clone(), + schedule_query: schedule_repo, + }; + (deps, channel_repo) +} + +#[tokio::test] +async fn m3u_empty_channels() { + let (deps, _) = make_deps(); + + let result = m3u::execute( + &deps, + GetM3uQuery { + base_url: "http://localhost:3000".into(), + token: Some("tok123".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(result, "#EXTM3U\n"); +} + +#[tokio::test] +async fn m3u_includes_channels() { + let (deps, repo) = make_deps(); + + let ch = Channel::new(UserId::generate(), "Test TV", "UTC"); + repo.channels + .lock() + .unwrap() + .insert(ch.id().value(), ch.clone()); + + let result = m3u::execute( + &deps, + GetM3uQuery { + base_url: "http://localhost:3000".into(), + token: Some("mytoken".into()), + }, + ) + .await + .unwrap(); + + assert!(result.starts_with("#EXTM3U\n")); + assert!(result.contains("Test TV")); + assert!(result.contains("token=mytoken")); +} + +#[tokio::test] +async fn m3u_no_token() { + let (deps, repo) = make_deps(); + + let ch = Channel::new(UserId::generate(), "Ch1", "UTC"); + repo.channels + .lock() + .unwrap() + .insert(ch.id().value(), ch); + + let result = m3u::execute( + &deps, + GetM3uQuery { + base_url: "http://localhost:3000".into(), + token: None, + }, + ) + .await + .unwrap(); + + assert!(result.contains("token=")); +} diff --git a/crates/application/src/iptv/xmltv.rs b/crates/application/src/iptv/xmltv.rs new file mode 100644 index 0000000..a6c3657 --- /dev/null +++ b/crates/application/src/iptv/xmltv.rs @@ -0,0 +1,27 @@ +use std::collections::HashMap; + +use chrono::Utc; + +use domain::services::iptv::generate_xmltv; +use domain::DomainResult; + +use super::deps::IptvDeps; +use super::queries::GetXmltvQuery; + +/// Generate an XMLTV EPG document for all channels with active schedules. +/// +/// Flow: fetch all channels -> for each, find active schedule -> collect slots +/// -> delegate to domain::generate_xmltv -> return string. +pub async fn execute(deps: &IptvDeps, _query: GetXmltvQuery) -> DomainResult { + let channels = deps.channel_query.find_all().await?; + let now = Utc::now(); + + let mut slots_by_channel = HashMap::new(); + for ch in &channels { + if let Some(schedule) = deps.schedule_query.find_active(ch.id(), now).await? { + slots_by_channel.insert(ch.id(), schedule.slots().to_vec()); + } + } + + Ok(generate_xmltv(&channels, &slots_by_channel)) +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index ee526d2..e8bf4f9 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,4 +1,8 @@ +pub mod admin; pub mod auth; pub mod channels; +pub mod config_snapshots; +pub mod iptv; pub mod library; +pub mod providers; pub mod schedule; diff --git a/crates/application/src/providers/commands.rs b/crates/application/src/providers/commands.rs new file mode 100644 index 0000000..5eff0e9 --- /dev/null +++ b/crates/application/src/providers/commands.rs @@ -0,0 +1,12 @@ +/// Insert or update a provider configuration. +pub struct UpsertProviderCommand { + pub id: String, + pub provider_type: String, + pub config_json: String, + pub enabled: bool, +} + +/// Delete a provider configuration. +pub struct DeleteProviderCommand { + pub id: String, +} diff --git a/crates/application/src/providers/delete.rs b/crates/application/src/providers/delete.rs new file mode 100644 index 0000000..34cc6fc --- /dev/null +++ b/crates/application/src/providers/delete.rs @@ -0,0 +1,9 @@ +use domain::DomainResult; + +use super::commands::DeleteProviderCommand; +use super::deps::ProviderDeps; + +/// Delete a provider configuration by ID. +pub async fn execute(deps: &ProviderDeps, cmd: DeleteProviderCommand) -> DomainResult<()> { + deps.provider_config_command.delete(&cmd.id).await +} diff --git a/crates/application/src/providers/deps.rs b/crates/application/src/providers/deps.rs new file mode 100644 index 0000000..3976cc2 --- /dev/null +++ b/crates/application/src/providers/deps.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use domain::ports::{ProviderConfigCommand, ProviderConfigQuery}; + +/// Dependencies for provider config use cases. +pub struct ProviderDeps { + pub provider_config_command: Arc, + pub provider_config_query: Arc, +} diff --git a/crates/application/src/providers/get.rs b/crates/application/src/providers/get.rs new file mode 100644 index 0000000..5e00c75 --- /dev/null +++ b/crates/application/src/providers/get.rs @@ -0,0 +1,13 @@ +use domain::models::ProviderConfigRow; +use domain::DomainResult; + +use super::deps::ProviderDeps; +use super::queries::GetProviderQuery; + +/// Get a provider configuration by ID. +pub async fn execute( + deps: &ProviderDeps, + query: GetProviderQuery, +) -> DomainResult> { + deps.provider_config_query.get_by_id(&query.id).await +} diff --git a/crates/application/src/providers/list.rs b/crates/application/src/providers/list.rs new file mode 100644 index 0000000..7451580 --- /dev/null +++ b/crates/application/src/providers/list.rs @@ -0,0 +1,17 @@ +use domain::models::ProviderConfigRow; +use domain::DomainResult; + +use super::deps::ProviderDeps; +use super::queries::ListProvidersQuery; + +/// List all provider configurations. +pub async fn execute( + deps: &ProviderDeps, + _query: ListProvidersQuery, +) -> DomainResult> { + deps.provider_config_query.get_all().await +} + +#[cfg(test)] +#[path = "tests/list.rs"] +mod tests; diff --git a/crates/application/src/providers/mod.rs b/crates/application/src/providers/mod.rs new file mode 100644 index 0000000..d4abf10 --- /dev/null +++ b/crates/application/src/providers/mod.rs @@ -0,0 +1,11 @@ +pub mod commands; +pub mod delete; +pub mod deps; +pub mod get; +pub mod list; +pub mod queries; +pub mod upsert; + +pub use commands::{DeleteProviderCommand, UpsertProviderCommand}; +pub use deps::ProviderDeps; +pub use queries::{GetProviderQuery, ListProvidersQuery}; diff --git a/crates/application/src/providers/queries.rs b/crates/application/src/providers/queries.rs new file mode 100644 index 0000000..a5290b2 --- /dev/null +++ b/crates/application/src/providers/queries.rs @@ -0,0 +1,7 @@ +/// List all provider configurations. +pub struct ListProvidersQuery; + +/// Get a provider configuration by ID. +pub struct GetProviderQuery { + pub id: String, +} diff --git a/crates/application/src/providers/tests/list.rs b/crates/application/src/providers/tests/list.rs new file mode 100644 index 0000000..3c077cd --- /dev/null +++ b/crates/application/src/providers/tests/list.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use domain::testing::InMemoryProviderConfig; + +use crate::providers::commands::UpsertProviderCommand; +use crate::providers::deps::ProviderDeps; +use crate::providers::queries::ListProvidersQuery; +use crate::providers::{list, upsert}; + +fn make_deps() -> ProviderDeps { + let repo = Arc::new(InMemoryProviderConfig::new()); + ProviderDeps { + provider_config_command: repo.clone(), + provider_config_query: repo, + } +} + +#[tokio::test] +async fn list_empty() { + let deps = make_deps(); + let providers = list::execute(&deps, ListProvidersQuery).await.unwrap(); + assert!(providers.is_empty()); +} + +#[tokio::test] +async fn list_returns_all_providers() { + let deps = make_deps(); + + for id in ["jf-1", "local-1"] { + upsert::execute( + &deps, + UpsertProviderCommand { + id: id.into(), + provider_type: "jellyfin".into(), + config_json: "{}".into(), + enabled: true, + }, + ) + .await + .unwrap(); + } + + let providers = list::execute(&deps, ListProvidersQuery).await.unwrap(); + assert_eq!(providers.len(), 2); +} diff --git a/crates/application/src/providers/tests/upsert.rs b/crates/application/src/providers/tests/upsert.rs new file mode 100644 index 0000000..b72c1e1 --- /dev/null +++ b/crates/application/src/providers/tests/upsert.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use domain::testing::InMemoryProviderConfig; + +use crate::providers::commands::UpsertProviderCommand; +use crate::providers::deps::ProviderDeps; +use crate::providers::upsert; + +fn make_deps() -> ProviderDeps { + let repo = Arc::new(InMemoryProviderConfig::new()); + ProviderDeps { + provider_config_command: repo.clone(), + provider_config_query: repo, + } +} + +#[tokio::test] +async fn upsert_stores_provider() { + let deps = make_deps(); + + upsert::execute( + &deps, + UpsertProviderCommand { + id: "jf-1".into(), + provider_type: "jellyfin".into(), + config_json: r#"{"url":"http://localhost:8096"}"#.into(), + enabled: true, + }, + ) + .await + .unwrap(); + + let row = deps.provider_config_query.get_by_id("jf-1").await.unwrap(); + assert!(row.is_some()); + let row = row.unwrap(); + assert_eq!(row.provider_type(), "jellyfin"); + assert!(row.enabled()); +} + +#[tokio::test] +async fn upsert_overwrites_existing() { + let deps = make_deps(); + + upsert::execute( + &deps, + UpsertProviderCommand { + id: "jf-1".into(), + provider_type: "jellyfin".into(), + config_json: r#"{"url":"http://old"}"#.into(), + enabled: true, + }, + ) + .await + .unwrap(); + + upsert::execute( + &deps, + UpsertProviderCommand { + id: "jf-1".into(), + provider_type: "jellyfin".into(), + config_json: r#"{"url":"http://new"}"#.into(), + enabled: false, + }, + ) + .await + .unwrap(); + + let row = deps + .provider_config_query + .get_by_id("jf-1") + .await + .unwrap() + .unwrap(); + assert!(row.config_json().contains("new")); + assert!(!row.enabled()); +} diff --git a/crates/application/src/providers/upsert.rs b/crates/application/src/providers/upsert.rs new file mode 100644 index 0000000..a3e1b7b --- /dev/null +++ b/crates/application/src/providers/upsert.rs @@ -0,0 +1,21 @@ +use domain::models::ProviderConfigRow; +use domain::DomainResult; + +use super::commands::UpsertProviderCommand; +use super::deps::ProviderDeps; + +/// Insert or update a provider configuration. +pub async fn execute(deps: &ProviderDeps, cmd: UpsertProviderCommand) -> DomainResult<()> { + let row = ProviderConfigRow::from_persistence( + cmd.id, + cmd.provider_type, + cmd.config_json, + cmd.enabled, + String::new(), + ); + deps.provider_config_command.upsert(&row).await +} + +#[cfg(test)] +#[path = "tests/upsert.rs"] +mod tests;