diff --git a/Cargo.lock b/Cargo.lock index b675c78..6082678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -964,6 +979,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -982,8 +1008,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1600,6 +1628,33 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "mcp" +version = "0.1.0" +dependencies = [ + "adapter-auth", + "adapter-event-publisher", + "adapter-jellyfin", + "adapter-postgres", + "adapter-sqlite", + "anyhow", + "application", + "async-trait", + "chrono", + "domain", + "dotenvy", + "infra-wiring", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1920,6 +1975,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -2370,6 +2431,38 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rsa" version = "0.9.10" @@ -2483,6 +2576,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2507,6 +2612,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2596,6 +2713,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.150" diff --git a/Cargo.toml b/Cargo.toml index a791023..ad277f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/postgres", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation"] +members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common", "crates/adapters/sqlite", "crates/adapters/postgres", "crates/adapters/auth", "crates/adapters/jellyfin", "crates/adapters/local-files", "crates/adapters/event-publisher", "crates/presentation", "crates/mcp"] exclude = ["k-tv-backend", "k-tv-frontend"] resolver = "2" diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..0eb0357 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "mcp" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "k-tv-mcp" +path = "src/main.rs" + +[features] +default = ["sqlite", "jellyfin"] +sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"] +postgres = ["dep:adapter-postgres", "infra-wiring/postgres"] +jellyfin = ["dep:adapter-jellyfin"] + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +infra-wiring = { workspace = true } +adapter-auth = { workspace = true } +adapter-event-publisher = { workspace = true } + +adapter-sqlite = { workspace = true, optional = true } +adapter-postgres = { workspace = true, optional = true } +adapter-jellyfin = { workspace = true, optional = true } + +rmcp = { version = "0.1", features = ["server", "transport-io"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = "1" +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +schemars = "0.8" +async-trait = { workspace = true } +dotenvy = "0.15" diff --git a/crates/mcp/src/error.rs b/crates/mcp/src/error.rs new file mode 100644 index 0000000..72b3684 --- /dev/null +++ b/crates/mcp/src/error.rs @@ -0,0 +1,13 @@ +use domain::DomainError; + +pub fn domain_err(e: DomainError) -> String { + serde_json::json!({"error": e.to_string()}).to_string() +} + +pub fn json_err(e: serde_json::Error) -> String { + serde_json::json!({"error": format!("serialization failed: {e}")}).to_string() +} + +pub fn ok_json(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(json_err) +} diff --git a/crates/mcp/src/main.rs b/crates/mcp/src/main.rs new file mode 100644 index 0000000..523edba --- /dev/null +++ b/crates/mcp/src/main.rs @@ -0,0 +1,335 @@ +use std::sync::Arc; + +use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol}; +use domain::{DomainError, DomainResult, MediaFilter, MediaItemId, MediaItem, ScheduleEngineService}; +use domain::ports::StreamQuality; +use infra_wiring::DbPool; +use tracing::info; +use uuid::Uuid; + +mod error; +mod server; +mod tools; + +use server::KTvMcpServer; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("mcp=info".parse().unwrap()), + ) + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()); + + let owner_id: Uuid = std::env::var("MCP_USER_ID") + .map_err(|_| anyhow::anyhow!("MCP_USER_ID env var is required (UUID of the user)"))? + .parse() + .map_err(|_| anyhow::anyhow!("MCP_USER_ID must be a valid UUID"))?; + + info!("Connecting to database: {}", database_url); + + let pool = DbPool::connect(&database_url).await?; + pool.run_migrations().await?; + + let wire = wire_repositories(&pool)?; + + let provider_registry = build_provider_registry().await; + + let event_bus = Arc::new(adapter_event_publisher::ChannelEventBus::new(64)); + let event_publisher: Arc = event_bus.clone(); + + let schedule_engine = Arc::new(ScheduleEngineService::new( + provider_registry.clone(), + wire.channel_query.clone(), + wire.schedule_query.clone(), + wire.schedule_command.clone(), + )); + + let channel_cmd_deps = Arc::new(application::channels::ChannelCommandDeps { + channel_command: wire.channel_command.clone(), + channel_query: wire.channel_query.clone(), + event_publisher: event_publisher.clone(), + }); + + let channel_query_deps = Arc::new(application::channels::ChannelQueryDeps { + channel_query: wire.channel_query.clone(), + }); + + let schedule_deps = Arc::new(application::schedule::ScheduleDeps { + schedule_engine, + channel_query: wire.channel_query.clone(), + schedule_query: wire.schedule_query.clone(), + schedule_command: wire.schedule_command.clone(), + event_publisher: event_publisher.clone(), + }); + + let library_query_deps = Arc::new(application::library::LibraryQueryDeps { + library_query: wire.library_query.clone(), + }); + + let server = KTvMcpServer { + channel_cmd_deps, + channel_query_deps, + schedule_deps, + library_query_deps, + owner_id, + }; + + info!("K-TV MCP server starting (stdio transport), owner_id={}", owner_id); + + use rmcp::ServiceExt; + let service = server + .serve(rmcp::transport::stdio()) + .await + .inspect_err(|e| tracing::error!("MCP server error: {e}"))?; + + service.waiting().await?; + + Ok(()) +} + +struct WireOutput { + channel_command: Arc, + channel_query: Arc, + schedule_command: Arc, + schedule_query: Arc, + library_query: Arc, +} + +fn wire_repositories(pool: &DbPool) -> anyhow::Result { + match pool { + #[cfg(feature = "sqlite")] + DbPool::Sqlite(sqlite_pool) => { + let w = adapter_sqlite::wire(sqlite_pool.clone()); + Ok(WireOutput { + channel_command: w.channel_command, + channel_query: w.channel_query, + schedule_command: w.schedule_command, + schedule_query: w.schedule_query, + library_query: w.library_query, + }) + } + #[cfg(feature = "postgres")] + DbPool::Postgres(pg_pool) => { + let w = adapter_postgres::wire(pg_pool.clone()); + Ok(WireOutput { + channel_command: w.channel_command, + channel_query: w.channel_query, + schedule_command: w.schedule_command, + schedule_query: w.schedule_query, + library_query: w.library_query, + }) + } + } +} + +async fn build_provider_registry() -> Arc { + let mut providers: Vec<(String, Arc)> = Vec::new(); + + #[cfg(feature = "jellyfin")] + if let (Some(url), Some(api_key), Some(user_id)) = ( + std::env::var("JELLYFIN_BASE_URL").ok(), + std::env::var("JELLYFIN_API_KEY").ok(), + std::env::var("JELLYFIN_USER_ID").ok(), + ) { + info!("Media provider: Jellyfin at {}", url); + providers.push(( + "jellyfin".to_string(), + Arc::new(adapter_jellyfin::JellyfinMediaProvider::new( + adapter_jellyfin::JellyfinConfig { + base_url: url, + api_key, + user_id, + }, + )), + )); + } + + if providers.is_empty() { + tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL."); + providers.push(("noop".to_string(), Arc::new(NoopMediaProvider))); + } + + Arc::new(SimpleProviderRegistry::new(providers)) +} + +struct NoopMediaProvider; + +#[async_trait::async_trait] +impl IMediaProvider for NoopMediaProvider { + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + collections: false, + series: false, + genres: false, + tags: false, + decade: false, + search: false, + streaming_protocol: StreamingProtocol::DirectFile, + rescan: false, + transcode: false, + } + } + + async fn fetch_items(&self, _: &MediaFilter) -> DomainResult> { + Err(DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } + + async fn fetch_by_id(&self, _: &MediaItemId) -> DomainResult> { + Err(DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } + + async fn get_stream_url(&self, _: &MediaItemId, _: &StreamQuality) -> DomainResult { + Err(DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } +} + +struct SimpleProviderRegistry { + providers: Vec<(String, Arc)>, +} + +impl SimpleProviderRegistry { + fn new(providers: Vec<(String, Arc)>) -> Self { + Self { providers } + } + + fn get(&self, id: &str) -> Option<&Arc> { + self.providers.iter().find(|(k, _)| k == id).map(|(_, v)| v) + } + + fn primary(&self) -> Option<&Arc> { + self.providers.first().map(|(_, v)| v) + } + + fn extract_provider_id(item_id: &str) -> Option<&str> { + item_id.find("::").map(|pos| &item_id[..pos]) + } +} + +#[async_trait::async_trait] +impl IProviderRegistry for SimpleProviderRegistry { + async fn fetch_items( + &self, + provider_id: &str, + filter: &MediaFilter, + ) -> DomainResult> { + let id = if provider_id.is_empty() { + self.providers.first().map(|(k, _)| k.as_str()).unwrap_or("") + } else { + provider_id + }; + let provider = self + .get(id) + .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; + provider.fetch_items(filter).await + } + + async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult> { + let id_str = item_id.value(); + if let Some(pid) = Self::extract_provider_id(id_str) + && let Some(provider) = self.get(pid) + { + return provider.fetch_by_id(item_id).await; + } + if let Some(provider) = self.primary() { + provider.fetch_by_id(item_id).await + } else { + Ok(None) + } + } + + async fn get_stream_url( + &self, + item_id: &MediaItemId, + quality: &StreamQuality, + ) -> DomainResult { + let id_str = item_id.value(); + if let Some(pid) = Self::extract_provider_id(id_str) + && let Some(provider) = self.get(pid) + { + return provider.get_stream_url(item_id, quality).await; + } + if let Some(provider) = self.primary() { + provider.get_stream_url(item_id, quality).await + } else { + Err(DomainError::InfrastructureError( + "No provider available".into(), + )) + } + } + + fn provider_ids(&self) -> Vec { + self.providers.iter().map(|(k, _)| k.clone()).collect() + } + + fn primary_id(&self) -> &str { + self.providers + .first() + .map(|(k, _)| k.as_str()) + .unwrap_or("") + } + + fn capabilities(&self, provider_id: &str) -> Option { + self.get(provider_id).map(|p| p.capabilities()) + } + + async fn list_collections( + &self, + provider_id: &str, + ) -> DomainResult> { + let id = if provider_id.is_empty() { + self.primary_id() + } else { + provider_id + }; + let provider = self + .get(id) + .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; + provider.list_collections().await + } + + async fn list_series( + &self, + provider_id: &str, + collection_id: Option<&str>, + ) -> DomainResult> { + let id = if provider_id.is_empty() { + self.primary_id() + } else { + provider_id + }; + let provider = self + .get(id) + .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; + provider.list_series(collection_id).await + } + + async fn list_genres( + &self, + provider_id: &str, + content_type: Option<&domain::ContentType>, + ) -> DomainResult> { + let id = if provider_id.is_empty() { + self.primary_id() + } else { + provider_id + }; + let provider = self + .get(id) + .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; + provider.list_genres(content_type).await + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 0000000..d554429 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,204 @@ +use std::sync::Arc; + +use application::{ + channels::{ChannelCommandDeps, ChannelQueryDeps}, + library::LibraryQueryDeps, + schedule::ScheduleDeps, +}; +use rmcp::{ + ServerHandler, + model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, + tool, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use crate::tools::{channels, library, schedule}; + +#[derive(Clone)] +pub struct KTvMcpServer { + pub channel_cmd_deps: Arc, + pub channel_query_deps: Arc, + pub schedule_deps: Arc, + pub library_query_deps: Arc, + pub owner_id: Uuid, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetChannelParams { + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateChannelParams { + pub name: String, + pub timezone: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateChannelParams { + pub id: String, + pub name: Option, + pub timezone: Option, + pub description: Option, + pub schedule_config_json: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteChannelParams { + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChannelIdParam { + pub channel_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchMediaParams { + pub content_type: Option, + pub genres: Option>, + pub search_term: Option, + pub series_names: Option>, + pub collections: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListGenresParams { + pub content_type: Option, +} + +fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string()) +} + +#[tool(tool_box)] +impl KTvMcpServer { + #[tool(description = "List all channels owned by the configured user")] + async fn list_channels(&self) -> String { + channels::list_channels(&self.channel_query_deps, self.owner_id).await + } + + #[tool(description = "Get a channel by UUID")] + async fn get_channel(&self, #[tool(aggr)] p: GetChannelParams) -> String { + match parse_uuid(&p.id) { + Ok(id) => channels::get_channel(&self.channel_query_deps, id).await, + Err(e) => e, + } + } + + #[tool(description = "Create a new channel with a name and IANA timezone")] + async fn create_channel(&self, #[tool(aggr)] p: CreateChannelParams) -> String { + channels::create_channel(&self.channel_cmd_deps, self.owner_id, &p.name, &p.timezone).await + } + + #[tool(description = "Update channel name, timezone, description, and/or schedule config")] + async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String { + let id = match parse_uuid(&p.id) { + Ok(id) => id, + Err(e) => return e, + }; + let schedule_config = match p.schedule_config_json { + Some(json) => match serde_json::from_str(&json) { + Ok(c) => Some(c), + Err(e) => { + return serde_json::json!({"error": format!("invalid schedule_config_json: {e}")}) + .to_string() + } + }, + None => None, + }; + channels::update_channel( + &self.channel_cmd_deps, + id, + self.owner_id, + p.name, + p.timezone, + p.description, + schedule_config, + ) + .await + } + + #[tool(description = "Delete a channel (must be owned by the configured user)")] + async fn delete_channel(&self, #[tool(aggr)] p: DeleteChannelParams) -> String { + match parse_uuid(&p.id) { + Ok(id) => channels::delete_channel(&self.channel_cmd_deps, id, self.owner_id).await, + Err(e) => e, + } + } + + #[tool(description = "Generate a fresh schedule for the given channel")] + async fn generate_schedule(&self, #[tool(aggr)] p: ChannelIdParam) -> String { + match parse_uuid(&p.channel_id) { + Ok(id) => schedule::generate_schedule(&self.schedule_deps, id).await, + Err(e) => e, + } + } + + #[tool(description = "Get the currently active schedule for a channel (returns null if none)")] + async fn get_active_schedule(&self, #[tool(aggr)] p: ChannelIdParam) -> String { + match parse_uuid(&p.channel_id) { + Ok(id) => schedule::get_active_schedule(&self.schedule_deps, id).await, + Err(e) => e, + } + } + + #[tool( + description = "Get what is currently broadcasting on a channel (returns null if in a gap or no schedule)" + )] + async fn get_current_broadcast(&self, #[tool(aggr)] p: ChannelIdParam) -> String { + match parse_uuid(&p.channel_id) { + Ok(id) => schedule::get_current_broadcast(&self.schedule_deps, id).await, + Err(e) => e, + } + } + + #[tool(description = "List media collections/libraries available in the library")] + async fn list_collections(&self) -> String { + library::list_collections(&self.library_query_deps).await + } + + #[tool( + description = "List genres available in the library, optionally filtered by content type (movie/episode/short)" + )] + async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String { + library::list_genres(&self.library_query_deps, p.content_type).await + } + + #[tool( + description = "Search media items. content_type: movie|episode|short. Returns JSON array of LibraryItem." + )] + async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String { + library::search_media( + &self.library_query_deps, + p.content_type, + p.genres.unwrap_or_default(), + p.search_term, + p.series_names.unwrap_or_default(), + p.collections.unwrap_or_default(), + ) + .await + } +} + +#[tool(tool_box)] +impl ServerHandler for KTvMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "k-tv-mcp".into(), + version: "0.1.0".into(), + }, + instructions: Some( + "K-TV MCP server. Create channels, define programming blocks, generate schedules. \ + All operations run as the user configured via MCP_USER_ID." + .into(), + ), + } + } +} diff --git a/crates/mcp/src/tools/channels.rs b/crates/mcp/src/tools/channels.rs new file mode 100644 index 0000000..685ef63 --- /dev/null +++ b/crates/mcp/src/tools/channels.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use application::channels::{ + ChannelCommandDeps, ChannelQueryDeps, CreateChannelCommand, DeleteChannelCommand, + GetChannelQuery, ListByOwnerQuery, UpdateChannelCommand, +}; +use uuid::Uuid; + +use crate::error::{domain_err, ok_json}; + +pub async fn list_channels( + query_deps: &Arc, + owner_id: Uuid, +) -> String { + let query = ListByOwnerQuery { owner_id }; + match application::channels::list_by_owner::execute(query_deps, query).await { + Ok(channels) => ok_json(&channels), + Err(e) => domain_err(e), + } +} + +pub async fn get_channel(query_deps: &Arc, id: Uuid) -> String { + let query = GetChannelQuery { channel_id: id }; + match application::channels::get::execute(query_deps, query).await { + Ok(Some(channel)) => ok_json(&channel), + Ok(None) => serde_json::json!({"error": "Channel not found"}).to_string(), + Err(e) => domain_err(e), + } +} + +pub async fn create_channel( + cmd_deps: &Arc, + owner_id: Uuid, + name: &str, + timezone: &str, +) -> String { + let cmd = CreateChannelCommand { + owner_id, + name: name.to_string(), + timezone: timezone.to_string(), + }; + match application::channels::create::execute(cmd_deps, cmd).await { + Ok(channel) => ok_json(&channel), + Err(e) => domain_err(e), + } +} + +pub async fn update_channel( + cmd_deps: &Arc, + channel_id: Uuid, + owner_id: Uuid, + name: Option, + timezone: Option, + description: Option, + schedule_config: Option, +) -> String { + let cmd = UpdateChannelCommand { + channel_id, + owner_id, + name, + description: description.map(Some), + timezone, + schedule_config, + recycle_policy: None, + auto_schedule: None, + }; + match application::channels::update::execute(cmd_deps, cmd).await { + Ok(channel) => ok_json(&channel), + Err(e) => domain_err(e), + } +} + +pub async fn delete_channel( + cmd_deps: &Arc, + channel_id: Uuid, + owner_id: Uuid, +) -> String { + let cmd = DeleteChannelCommand { + channel_id, + owner_id, + }; + match application::channels::delete::execute(cmd_deps, cmd).await { + Ok(()) => serde_json::json!({"deleted": channel_id}).to_string(), + Err(e) => domain_err(e), + } +} diff --git a/crates/mcp/src/tools/library.rs b/crates/mcp/src/tools/library.rs new file mode 100644 index 0000000..d0d9b6d --- /dev/null +++ b/crates/mcp/src/tools/library.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use application::library::{ + LibraryQueryDeps, ListCollectionsQuery, ListGenresQuery, SearchItemsQuery, +}; +use serde::Serialize; + +use crate::error::{domain_err, ok_json}; + +#[derive(Serialize)] +struct CollectionDto { + id: String, + name: String, + collection_type: Option, +} + +#[derive(Serialize)] +struct LibraryItemDto { + id: String, + provider_id: String, + external_id: String, + title: String, + content_type: String, + duration_secs: u32, + series_name: Option, + season_number: Option, + episode_number: Option, + year: Option, + genres: Vec, + tags: Vec, + collection_id: Option, + thumbnail_url: Option, +} + +#[derive(Serialize)] +struct SearchResult { + items: Vec, + total: u32, +} + +fn to_content_type_string(ct: &domain::ContentType) -> String { + match ct { + domain::ContentType::Movie => "movie".to_string(), + domain::ContentType::Episode => "episode".to_string(), + domain::ContentType::Short => "short".to_string(), + } +} + +pub async fn list_collections(deps: &Arc) -> String { + let query = ListCollectionsQuery { provider_id: None }; + match application::library::list_collections::execute(deps, query).await { + Ok(cols) => { + let dtos: Vec = cols + .into_iter() + .map(|c| CollectionDto { + id: c.id().to_string(), + name: c.name().to_string(), + collection_type: c.collection_type().map(|s| s.to_string()), + }) + .collect(); + ok_json(&dtos) + } + Err(e) => domain_err(e), + } +} + +pub async fn list_genres(deps: &Arc, content_type: Option) -> String { + let query = ListGenresQuery { + content_type, + provider_id: None, + }; + match application::library::list_genres::execute(deps, query).await { + Ok(genres) => ok_json(&genres), + Err(e) => domain_err(e), + } +} + +pub async fn search_media( + deps: &Arc, + content_type: Option, + genres: Vec, + search_term: Option, + series_names: Vec, + collections: Vec, +) -> String { + let query = SearchItemsQuery { + provider_id: None, + content_type, + genres, + search_term, + series_names, + collection_id: collections.first().cloned(), + season_number: None, + decade: None, + offset: 0, + limit: 50, + }; + match application::library::search::execute(deps, query).await { + Ok((items, total)) => { + let dtos: Vec = items + .into_iter() + .map(|i| LibraryItemDto { + id: i.id().to_string(), + provider_id: i.provider_id().to_string(), + external_id: i.external_id().to_string(), + title: i.title().to_string(), + content_type: to_content_type_string(i.content_type()), + duration_secs: i.duration_secs(), + series_name: i.series_name().map(|s| s.to_string()), + season_number: i.season_number(), + episode_number: i.episode_number(), + year: i.year(), + genres: i.genres().to_vec(), + tags: i.tags().to_vec(), + collection_id: i.collection_id().map(|s| s.to_string()), + thumbnail_url: i.thumbnail_url().map(|s| s.to_string()), + }) + .collect(); + ok_json(&SearchResult { items: dtos, total }) + } + Err(e) => domain_err(e), + } +} diff --git a/crates/mcp/src/tools/mod.rs b/crates/mcp/src/tools/mod.rs new file mode 100644 index 0000000..e2181ea --- /dev/null +++ b/crates/mcp/src/tools/mod.rs @@ -0,0 +1,3 @@ +pub mod channels; +pub mod library; +pub mod schedule; diff --git a/crates/mcp/src/tools/schedule.rs b/crates/mcp/src/tools/schedule.rs new file mode 100644 index 0000000..c1af4d8 --- /dev/null +++ b/crates/mcp/src/tools/schedule.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use application::schedule::{ + GenerateScheduleCommand, GetActiveScheduleQuery, GetCurrentBroadcastQuery, ScheduleDeps, +}; +use domain::ScheduledSlot; +use serde::Serialize; +use uuid::Uuid; + +use crate::error::{domain_err, ok_json}; + +#[derive(Serialize)] +struct CurrentBroadcastDto { + slot: ScheduledSlot, + offset_secs: u32, +} + +pub async fn generate_schedule(deps: &Arc, channel_id: Uuid) -> String { + let cmd = GenerateScheduleCommand { channel_id }; + match application::schedule::generate::execute(deps, cmd).await { + Ok(schedule) => ok_json(&schedule), + Err(e) => domain_err(e), + } +} + +pub async fn get_active_schedule(deps: &Arc, channel_id: Uuid) -> String { + let query = GetActiveScheduleQuery { channel_id }; + match application::schedule::get_active::execute(deps, query).await { + Ok(Some(schedule)) => ok_json(&schedule), + Ok(None) => "null".to_string(), + Err(e) => domain_err(e), + } +} + +pub async fn get_current_broadcast(deps: &Arc, channel_id: Uuid) -> String { + let query = GetCurrentBroadcastQuery { channel_id }; + match application::schedule::get_current_broadcast::execute(deps, query).await { + Ok(Some(b)) => { + let offset = b.offset_secs(); + ok_json(&CurrentBroadcastDto { + slot: b.into_slot(), + offset_secs: offset, + }) + } + Ok(None) => "null".to_string(), + Err(e) => domain_err(e), + } +}