feat(mcp): implement media channel management and scheduling features
This commit is contained in:
111
k-tv-backend/Cargo.lock
generated
111
k-tv-backend/Cargo.lock
generated
@@ -850,6 +850,21 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -923,6 +938,7 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1581,6 +1597,28 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
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]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -1918,6 +1956,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -2344,6 +2388,38 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
@@ -2489,6 +2565,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2513,6 +2601,18 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2602,6 +2702,17 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.148"
|
version = "1.0.148"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["domain", "infra", "api"]
|
members = ["domain", "infra", "api", "mcp"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ FROM debian:bookworm-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning)
|
# 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 .
|
COPY --from=builder /app/target/release/api .
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ auth-jwt = ["infra/auth-jwt"]
|
|||||||
jellyfin = ["infra/jellyfin"]
|
jellyfin = ["infra/jellyfin"]
|
||||||
local-files = ["infra/local-files", "dep:tokio-util"]
|
local-files = ["infra/local-files", "dep:tokio-util"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||||
"logging",
|
"logging",
|
||||||
@@ -25,7 +30,10 @@ infra = { path = "../infra", default-features = false, features = ["sqlite"] }
|
|||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
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 = "0.5.2"
|
||||||
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
||||||
|
|
||||||
|
|||||||
36
k-tv-backend/mcp/Cargo.toml
Normal file
36
k-tv-backend/mcp/Cargo.toml
Normal file
@@ -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"
|
||||||
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