feat(mcp): implement media channel management and scheduling features

This commit is contained in:
2026-03-14 23:19:24 +01:00
parent f7f4d92376
commit c53892159a
12 changed files with 878 additions and 3 deletions

View File

@@ -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<ChannelService>,
pub schedule_engine: Arc<ScheduleEngineService>,
pub media_provider: Arc<dyn IMediaProvider>,
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<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UpdateChannelParams {
/// Channel UUID
pub id: String,
pub name: Option<String>,
pub timezone: Option<String>,
pub description: Option<String>,
}
#[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<String>,
pub genres: Option<Vec<String>>,
pub search_term: Option<String>,
pub series_names: Option<Vec<String>>,
pub collections: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListGenresParams {
/// Optional content type: "movie", "episode", or "short"
pub content_type: Option<String>,
}
// ============================================================================
// Tool implementations
// ============================================================================
fn parse_uuid(s: &str) -> Result<Uuid, String> {
s.parse::<Uuid>()
.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<ProgrammingBlock> = 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<ContentType> {
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(),
),
}
}
}