diff --git a/k-tv-backend/Cargo.lock b/k-tv-backend/Cargo.lock index 250bddc..745e6b2 100644 --- a/k-tv-backend/Cargo.lock +++ b/k-tv-backend/Cargo.lock @@ -850,6 +850,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -923,6 +938,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1581,6 +1597,28 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "domain", + "dotenvy", + "infra", + "k-core", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1918,6 +1956,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" @@ -2344,6 +2388,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.17", + "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.9" @@ -2489,6 +2565,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" @@ -2513,6 +2601,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" @@ -2602,6 +2702,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.148" diff --git a/k-tv-backend/Cargo.toml b/k-tv-backend/Cargo.toml index 045eb21..c003225 100644 --- a/k-tv-backend/Cargo.toml +++ b/k-tv-backend/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["domain", "infra", "api"] +members = ["domain", "infra", "api", "mcp"] resolver = "2" diff --git a/k-tv-backend/Dockerfile b/k-tv-backend/Dockerfile index 381867f..444d98f 100644 --- a/k-tv-backend/Dockerfile +++ b/k-tv-backend/Dockerfile @@ -11,7 +11,11 @@ FROM debian:bookworm-slim WORKDIR /app # Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning) -RUN apt-get update && apt-get install -y libssl3 ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 \ + ca-certificates \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/api . diff --git a/k-tv-backend/api/Cargo.toml b/k-tv-backend/api/Cargo.toml index dbd304a..a8287ee 100644 --- a/k-tv-backend/api/Cargo.toml +++ b/k-tv-backend/api/Cargo.toml @@ -13,6 +13,11 @@ auth-jwt = ["infra/auth-jwt"] jellyfin = ["infra/jellyfin"] local-files = ["infra/local-files", "dep:tokio-util"] +[profile.release] +strip = true +lto = true +codegen-units = 1 + [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ "logging", @@ -25,7 +30,10 @@ infra = { path = "../infra", default-features = false, features = ["sqlite"] } # Web framework axum = { version = "0.8.8", features = ["macros"] } -axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] } +axum-extra = { version = "0.10", features = [ + "cookie-private", + "cookie-key-expansion", +] } tower = "0.5.2" tower-http = { version = "0.6.2", features = ["cors", "trace"] } diff --git a/k-tv-backend/mcp/Cargo.toml b/k-tv-backend/mcp/Cargo.toml new file mode 100644 index 0000000..9e8a0c7 --- /dev/null +++ b/k-tv-backend/mcp/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "mcp" +version = "0.1.0" +edition = "2024" +default-run = "mcp" + +[features] +default = ["sqlite", "jellyfin"] +sqlite = ["infra/sqlite"] +postgres = ["infra/postgres"] +jellyfin = ["infra/jellyfin"] +local-files = ["infra/local-files"] + +[dependencies] +domain = { path = "../domain" } +infra = { path = "../infra", default-features = false, features = ["sqlite"] } +k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ + "logging", + "db-sqlx", + "sqlite", +] } + +rmcp = { version = "0.1", features = ["server", "transport-io"] } + +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +schemars = "0.8" +dotenvy = "0.15" +async-trait = "0.1" diff --git a/k-tv-backend/mcp/src/error.rs b/k-tv-backend/mcp/src/error.rs new file mode 100644 index 0000000..ee0fd1f --- /dev/null +++ b/k-tv-backend/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(|e| json_err(e)) +} diff --git a/k-tv-backend/mcp/src/main.rs b/k-tv-backend/mcp/src/main.rs new file mode 100644 index 0000000..b0a170c --- /dev/null +++ b/k-tv-backend/mcp/src/main.rs @@ -0,0 +1,178 @@ +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use domain::{ + ChannelService, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItemId, + ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol, UserService, +}; +use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository}; +use infra::run_migrations; +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); + + #[cfg(all(feature = "sqlite", not(feature = "postgres")))] + let db_type = k_core::db::DbType::Sqlite; + + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let db_type = k_core::db::DbType::Postgres; + + #[cfg(all(feature = "sqlite", feature = "postgres"))] + let db_type = if database_url.starts_with("postgres") { + k_core::db::DbType::Postgres + } else { + k_core::db::DbType::Sqlite + }; + + let db_config = k_core::db::DatabaseConfig { + db_type, + url: database_url.clone(), + max_connections: 5, + min_connections: 1, + acquire_timeout: StdDuration::from_secs(30), + }; + + let db_pool = k_core::db::connect(&db_config).await?; + run_migrations(&db_pool).await?; + + let user_repo = build_user_repository(&db_pool).await?; + let channel_repo = build_channel_repository(&db_pool).await?; + let schedule_repo = build_schedule_repository(&db_pool).await?; + + let _user_service = UserService::new(user_repo); + let channel_service = ChannelService::new(channel_repo.clone()); + + let mut maybe_provider: Option> = None; + + #[cfg(feature = "jellyfin")] + { + let base_url = std::env::var("JELLYFIN_BASE_URL").ok(); + let api_key = std::env::var("JELLYFIN_API_KEY").ok(); + let user_id = std::env::var("JELLYFIN_USER_ID").ok(); + if let (Some(base_url), Some(api_key), Some(user_id)) = (base_url, api_key, user_id) { + info!("Media provider: Jellyfin at {}", base_url); + maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new( + infra::JellyfinConfig { + base_url, + api_key, + user_id, + }, + ))); + } + } + + #[cfg(feature = "local-files")] + if maybe_provider.is_none() { + if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) { + if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool { + let base_url = std::env::var("BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let lf_cfg = infra::LocalFilesConfig { + root_dir: dir, + base_url, + }; + let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await); + let scan_idx = Arc::clone(&idx); + tokio::spawn(async move { scan_idx.rescan().await; }); + maybe_provider = Some(Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg))); + } + } + } + + let media_provider: Arc = maybe_provider.unwrap_or_else(|| { + tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR."); + Arc::new(NoopMediaProvider) + }); + + let schedule_engine = ScheduleEngineService::new( + Arc::clone(&media_provider), + channel_repo, + schedule_repo, + ); + + let server = KTvMcpServer { + channel_service: Arc::new(channel_service), + schedule_engine: Arc::new(schedule_engine), + media_provider, + 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 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, + } + } + + 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(), + )) + } +} diff --git a/k-tv-backend/mcp/src/server.rs b/k-tv-backend/mcp/src/server.rs new file mode 100644 index 0000000..65a2060 --- /dev/null +++ b/k-tv-backend/mcp/src/server.rs @@ -0,0 +1,305 @@ +use std::sync::Arc; + +use domain::{ + ChannelService, ContentType, IMediaProvider, ProgrammingBlock, ScheduleConfig, + ScheduleEngineService, +}; +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_service: Arc, + pub schedule_engine: Arc, + pub media_provider: Arc, + pub owner_id: Uuid, +} + +// ============================================================================ +// Parameter types — Uuid fields stored as String to satisfy JsonSchema bound. +// ============================================================================ + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetChannelParams { + /// Channel UUID (e.g. "550e8400-e29b-41d4-a716-446655440000") + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateChannelParams { + pub name: String, + /// IANA timezone, e.g. "America/New_York" + pub timezone: String, + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateChannelParams { + /// Channel UUID + pub id: String, + pub name: Option, + pub timezone: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteChannelParams { + /// Channel UUID + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetScheduleConfigParams { + /// Channel UUID + pub channel_id: String, + /// JSON array of ProgrammingBlock objects + pub blocks_json: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AddBlockParams { + /// Channel UUID + pub channel_id: String, + /// ProgrammingBlock serialized as JSON + pub block_json: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RemoveBlockParams { + /// Channel UUID + pub channel_id: String, + /// Block UUID + pub block_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChannelIdParam { + /// Channel UUID + pub channel_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchMediaParams { + /// "movie", "episode", or "short" + 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 { + /// Optional content type: "movie", "episode", or "short" + pub content_type: Option, +} + +// ============================================================================ +// Tool implementations +// ============================================================================ + +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_service, 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_service, 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_service, + self.owner_id, + &p.name, + &p.timezone, + p.description, + ) + .await + } + + #[tool(description = "Update channel name, timezone, and/or description")] + async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String { + match parse_uuid(&p.id) { + Ok(id) => { + channels::update_channel( + &self.channel_service, + id, + p.name, + p.timezone, + p.description, + ) + .await + } + Err(e) => e, + } + } + + #[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_service, id, self.owner_id).await, + Err(e) => e, + } + } + + #[tool( + description = "Replace a channel's entire schedule config. blocks_json is a JSON array of ProgrammingBlock objects." + )] + async fn set_schedule_config(&self, #[tool(aggr)] p: SetScheduleConfigParams) -> String { + let channel_id = match parse_uuid(&p.channel_id) { + Ok(id) => id, + Err(e) => return e, + }; + let blocks: Vec = match serde_json::from_str(&p.blocks_json) { + Ok(b) => b, + Err(e) => { + return serde_json::json!({"error": format!("invalid blocks_json: {e}")}) + .to_string() + } + }; + channels::set_schedule_config( + &self.channel_service, + channel_id, + ScheduleConfig { blocks }, + ) + .await + } + + #[tool( + description = "Append a ProgrammingBlock to a channel's schedule. block_json is a serialized ProgrammingBlock." + )] + async fn add_programming_block(&self, #[tool(aggr)] p: AddBlockParams) -> String { + let channel_id = match parse_uuid(&p.channel_id) { + Ok(id) => id, + Err(e) => return e, + }; + let block: ProgrammingBlock = match serde_json::from_str(&p.block_json) { + Ok(b) => b, + Err(e) => { + return serde_json::json!({"error": format!("invalid block_json: {e}")}).to_string() + } + }; + channels::add_programming_block(&self.channel_service, channel_id, block).await + } + + #[tool(description = "Remove a programming block from a channel's schedule by block UUID")] + async fn remove_programming_block(&self, #[tool(aggr)] p: RemoveBlockParams) -> String { + let channel_id = match parse_uuid(&p.channel_id) { + Ok(id) => id, + Err(e) => return e, + }; + let block_id = match parse_uuid(&p.block_id) { + Ok(id) => id, + Err(e) => return e, + }; + channels::remove_programming_block(&self.channel_service, channel_id, block_id).await + } + + #[tool(description = "Generate a fresh 48-hour 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_engine, 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_engine, 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_engine, id).await, + Err(e) => e, + } + } + + #[tool(description = "List media collections/libraries available in the configured provider")] + async fn list_collections(&self) -> String { + library::list_collections(&self.media_provider).await + } + + #[tool( + description = "List genres available in the provider, optionally filtered by content type (movie/episode/short)" + )] + async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String { + let ct = p.content_type.as_deref().and_then(parse_content_type); + library::list_genres(&self.media_provider, ct).await + } + + #[tool( + description = "Search media items. content_type: movie|episode|short. Returns JSON array of MediaItem." + )] + async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String { + let ct = p.content_type.as_deref().and_then(parse_content_type); + library::search_media( + &self.media_provider, + ct, + p.genres.unwrap_or_default(), + p.search_term, + p.series_names.unwrap_or_default(), + p.collections.unwrap_or_default(), + ) + .await + } +} + +fn parse_content_type(s: &str) -> Option { + match s { + "movie" => Some(ContentType::Movie), + "episode" => Some(ContentType::Episode), + "short" => Some(ContentType::Short), + _ => None, + } +} + +// ============================================================================ +// ServerHandler +// ============================================================================ + +#[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/k-tv-backend/mcp/src/tools/channels.rs b/k-tv-backend/mcp/src/tools/channels.rs new file mode 100644 index 0000000..b1012fd --- /dev/null +++ b/k-tv-backend/mcp/src/tools/channels.rs @@ -0,0 +1,127 @@ +use domain::{Channel, ChannelService, ScheduleConfig, UserId}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::{domain_err, ok_json}; + +pub async fn list_channels(svc: &Arc, owner_id: UserId) -> String { + match svc.find_by_owner(owner_id).await { + Ok(channels) => ok_json(&channels), + Err(e) => domain_err(e), + } +} + +pub async fn get_channel(svc: &Arc, id: Uuid) -> String { + match svc.find_by_id(id).await { + Ok(channel) => ok_json(&channel), + Err(e) => domain_err(e), + } +} + +pub async fn create_channel( + svc: &Arc, + owner_id: UserId, + name: &str, + timezone: &str, + description: Option, +) -> String { + let channel = match svc.create(owner_id, name, timezone).await { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + if description.is_none() { + return ok_json(&channel); + } + let mut channel: Channel = channel; + channel.description = description; + channel.updated_at = chrono::Utc::now(); + match svc.update(channel).await { + Ok(c) => ok_json(&c), + Err(e) => domain_err(e), + } +} + +pub async fn update_channel( + svc: &Arc, + id: Uuid, + name: Option, + timezone: Option, + description: Option, +) -> String { + let mut channel: Channel = match svc.find_by_id(id).await { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + if let Some(n) = name { + channel.name = n; + } + if let Some(tz) = timezone { + channel.timezone = tz; + } + if description.is_some() { + channel.description = description; + } + channel.updated_at = chrono::Utc::now(); + match svc.update(channel).await { + Ok(c) => ok_json(&c), + Err(e) => domain_err(e), + } +} + +pub async fn delete_channel(svc: &Arc, id: Uuid, owner_id: UserId) -> String { + match svc.delete(id, owner_id).await { + Ok(()) => serde_json::json!({"deleted": id}).to_string(), + Err(e) => domain_err(e), + } +} + +pub async fn set_schedule_config( + svc: &Arc, + channel_id: Uuid, + config: ScheduleConfig, +) -> String { + let mut channel: Channel = match svc.find_by_id(channel_id).await { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + channel.schedule_config = config; + channel.updated_at = chrono::Utc::now(); + match svc.update(channel).await { + Ok(c) => ok_json(&c), + Err(e) => domain_err(e), + } +} + +pub async fn add_programming_block( + svc: &Arc, + channel_id: Uuid, + block: domain::ProgrammingBlock, +) -> String { + let mut channel: Channel = match svc.find_by_id(channel_id).await { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + channel.schedule_config.blocks.push(block); + channel.updated_at = chrono::Utc::now(); + match svc.update(channel).await { + Ok(c) => ok_json(&c), + Err(e) => domain_err(e), + } +} + +pub async fn remove_programming_block( + svc: &Arc, + channel_id: Uuid, + block_id: Uuid, +) -> String { + let mut channel: Channel = match svc.find_by_id(channel_id).await { + Ok(c) => c, + Err(e) => return domain_err(e), + }; + channel.schedule_config.blocks.retain(|b| b.id != block_id); + channel.updated_at = chrono::Utc::now(); + match svc.update(channel).await { + Ok(c) => ok_json(&c), + Err(e) => domain_err(e), + } +} diff --git a/k-tv-backend/mcp/src/tools/library.rs b/k-tv-backend/mcp/src/tools/library.rs new file mode 100644 index 0000000..51456bf --- /dev/null +++ b/k-tv-backend/mcp/src/tools/library.rs @@ -0,0 +1,43 @@ +use domain::{ContentType, IMediaProvider, MediaFilter}; +use std::sync::Arc; + +use crate::error::{domain_err, ok_json}; + +pub async fn list_collections(provider: &Arc) -> String { + match provider.list_collections().await { + Ok(cols) => ok_json(&cols), + Err(e) => domain_err(e), + } +} + +pub async fn list_genres( + provider: &Arc, + content_type: Option, +) -> String { + match provider.list_genres(content_type.as_ref()).await { + Ok(genres) => ok_json(&genres), + Err(e) => domain_err(e), + } +} + +pub async fn search_media( + provider: &Arc, + content_type: Option, + genres: Vec, + search_term: Option, + series_names: Vec, + collections: Vec, +) -> String { + let filter = MediaFilter { + content_type, + genres, + search_term, + series_names, + collections, + ..Default::default() + }; + match provider.fetch_items(&filter).await { + Ok(items) => ok_json(&items), + Err(e) => domain_err(e), + } +} diff --git a/k-tv-backend/mcp/src/tools/mod.rs b/k-tv-backend/mcp/src/tools/mod.rs new file mode 100644 index 0000000..e2181ea --- /dev/null +++ b/k-tv-backend/mcp/src/tools/mod.rs @@ -0,0 +1,3 @@ +pub mod channels; +pub mod library; +pub mod schedule; diff --git a/k-tv-backend/mcp/src/tools/schedule.rs b/k-tv-backend/mcp/src/tools/schedule.rs new file mode 100644 index 0000000..3631c08 --- /dev/null +++ b/k-tv-backend/mcp/src/tools/schedule.rs @@ -0,0 +1,47 @@ +use chrono::Utc; +use domain::{ScheduleEngineService, ScheduledSlot}; +use serde::Serialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::error::{domain_err, ok_json}; + +/// Serializable DTO for CurrentBroadcast (domain type does not derive Serialize). +#[derive(Serialize)] +struct CurrentBroadcastDto { + slot: ScheduledSlot, + offset_secs: u32, +} + +pub async fn generate_schedule(engine: &Arc, channel_id: Uuid) -> String { + match engine.generate_schedule(channel_id, Utc::now()).await { + Ok(schedule) => ok_json(&schedule), + Err(e) => domain_err(e), + } +} + +pub async fn get_active_schedule(engine: &Arc, channel_id: Uuid) -> String { + match engine.get_active_schedule(channel_id, Utc::now()).await { + Ok(Some(schedule)) => ok_json(&schedule), + Ok(None) => "null".to_string(), + Err(e) => domain_err(e), + } +} + +pub async fn get_current_broadcast( + engine: &Arc, + channel_id: Uuid, +) -> String { + let schedule = match engine.get_active_schedule(channel_id, Utc::now()).await { + Ok(Some(s)) => s, + Ok(None) => return "null".to_string(), + Err(e) => return domain_err(e), + }; + match ScheduleEngineService::get_current_broadcast(&schedule, Utc::now()) { + Some(b) => ok_json(&CurrentBroadcastDto { + slot: b.slot, + offset_secs: b.offset_secs, + }), + None => "null".to_string(), + } +}