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

111
k-tv-backend/Cargo.lock generated
View File

@@ -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"

View File

@@ -1,3 +1,3 @@
[workspace] [workspace]
members = ["domain", "infra", "api"] members = ["domain", "infra", "api", "mcp"]
resolver = "2" resolver = "2"

View File

@@ -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 .

View File

@@ -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"] }

View 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"

View 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))
}

View 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(),
))
}
}

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(),
),
}
}
}

View 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),
}
}

View 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),
}
}

View File

@@ -0,0 +1,3 @@
pub mod channels;
pub mod library;
pub mod schedule;

View 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(),
}
}