feat(mcp): implement media channel management and scheduling features
This commit is contained in:
13
k-tv-backend/mcp/src/error.rs
Normal file
13
k-tv-backend/mcp/src/error.rs
Normal file
@@ -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<T: serde::Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|e| json_err(e))
|
||||
}
|
||||
178
k-tv-backend/mcp/src/main.rs
Normal file
178
k-tv-backend/mcp/src/main.rs
Normal file
@@ -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<Arc<dyn IMediaProvider>> = 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<dyn IMediaProvider> = 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<Vec<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_by_id(&self, _: &MediaItemId) -> DomainResult<Option<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_: &MediaItemId,
|
||||
_: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
305
k-tv-backend/mcp/src/server.rs
Normal file
305
k-tv-backend/mcp/src/server.rs
Normal 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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
127
k-tv-backend/mcp/src/tools/channels.rs
Normal file
127
k-tv-backend/mcp/src/tools/channels.rs
Normal file
@@ -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<ChannelService>, 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<ChannelService>, 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<ChannelService>,
|
||||
owner_id: UserId,
|
||||
name: &str,
|
||||
timezone: &str,
|
||||
description: Option<String>,
|
||||
) -> 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<ChannelService>,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
timezone: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> 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<ChannelService>, 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<ChannelService>,
|
||||
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<ChannelService>,
|
||||
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<ChannelService>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
43
k-tv-backend/mcp/src/tools/library.rs
Normal file
43
k-tv-backend/mcp/src/tools/library.rs
Normal file
@@ -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<dyn IMediaProvider>) -> String {
|
||||
match provider.list_collections().await {
|
||||
Ok(cols) => ok_json(&cols),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_genres(
|
||||
provider: &Arc<dyn IMediaProvider>,
|
||||
content_type: Option<ContentType>,
|
||||
) -> 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<dyn IMediaProvider>,
|
||||
content_type: Option<ContentType>,
|
||||
genres: Vec<String>,
|
||||
search_term: Option<String>,
|
||||
series_names: Vec<String>,
|
||||
collections: Vec<String>,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
3
k-tv-backend/mcp/src/tools/mod.rs
Normal file
3
k-tv-backend/mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod channels;
|
||||
pub mod library;
|
||||
pub mod schedule;
|
||||
47
k-tv-backend/mcp/src/tools/schedule.rs
Normal file
47
k-tv-backend/mcp/src/tools/schedule.rs
Normal file
@@ -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<ScheduleEngineService>, 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<ScheduleEngineService>, 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<ScheduleEngineService>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user