wire utoipa OpenAPI: #[utoipa::path] on all handlers, Scalar UI (#14)

This commit is contained in:
2026-07-12 14:29:52 +02:00
parent b00272aceb
commit e27e2ab6d1
18 changed files with 651 additions and 48 deletions

14
Cargo.lock generated
View File

@@ -1843,6 +1843,8 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"utoipa",
"utoipa-scalar",
"uuid", "uuid",
] ]
@@ -3029,6 +3031,18 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"axum",
"serde",
"serde_json",
"utoipa",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"

View File

@@ -24,6 +24,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
utoipa = { version = "5", features = ["chrono", "uuid"] } utoipa = { version = "5", features = ["chrono", "uuid"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }
jsonwebtoken = "9" jsonwebtoken = "9"
# Internal crates # Internal crates

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -17,7 +17,7 @@ pub struct ActivityEventResponse {
pub channel_id: Option<Uuid>, pub channel_id: Option<Uuid>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ActivityLogParams { pub struct ActivityLogParams {
pub limit: Option<u32>, pub limit: Option<u32>,
} }

View File

@@ -1,7 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct IptvParams { pub struct IptvParams {
pub token: Option<String>, pub token: Option<String>,
} }

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use crate::common::enum_to_string; use crate::common::enum_to_string;
@@ -128,7 +128,7 @@ impl From<domain::LibrarySyncLogEntry> for SyncStatusEntry {
} }
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct LibrarySearchParams { pub struct LibrarySearchParams {
pub provider: Option<String>, pub provider: Option<String>,
pub content_type: Option<String>, pub content_type: Option<String>,
@@ -144,12 +144,12 @@ pub struct LibrarySearchParams {
pub limit: Option<u32>, pub limit: Option<u32>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ProviderParam { pub struct ProviderParam {
pub provider: Option<String>, pub provider: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ShowsParams { pub struct ShowsParams {
pub provider: Option<String>, pub provider: Option<String>,
pub search_term: Option<String>, pub search_term: Option<String>,
@@ -157,13 +157,13 @@ pub struct ShowsParams {
pub genres: Vec<String>, pub genres: Vec<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct SeasonsParams { pub struct SeasonsParams {
pub series_name: String, pub series_name: String,
pub provider: Option<String>, pub provider: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct GenresParams { pub struct GenresParams {
pub content_type: Option<String>, pub content_type: Option<String>,
pub provider: Option<String>, pub provider: Option<String>,

View File

@@ -14,7 +14,7 @@ use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::tools::{channels, ical, library, schedule}; use crate::tools::{channels, library, schedule};
const SERVER_NAME: &str = "k-tv-mcp"; const SERVER_NAME: &str = "k-tv-mcp";
@@ -201,16 +201,6 @@ impl KTvMcpServer {
) )
.await .await
} }
#[tool(
description = "Export a channel's schedule as iCalendar (.ics). Returns RFC 5545 text."
)]
async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String {
match parse_uuid(&p.channel_id) {
Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await,
Err(e) => e,
}
}
} }
#[tool(tool_box)] #[tool(tool_box)]

View File

@@ -28,6 +28,10 @@ adapter-sqlite = { workspace = true, optional = true }
adapter-jellyfin = { workspace = true, optional = true } adapter-jellyfin = { workspace = true, optional = true }
adapter-local-files = { workspace = true, optional = true } adapter-local-files = { workspace = true, optional = true }
# OpenAPI
utoipa = { workspace = true }
utoipa-scalar = { workspace = true }
# Framework # Framework
axum = { workspace = true } axum = { workspace = true }
axum-extra = { workspace = true, features = ["typed-header"] } axum-extra = { workspace = true, features = ["typed-header"] }

View File

@@ -11,6 +11,17 @@ use crate::state::AppState;
const DEFAULT_ACTIVITY_LIMIT: u32 = 50; const DEFAULT_ACTIVITY_LIMIT: u32 = 50;
#[utoipa::path(
get,
path = "/api/v1/admin/settings",
tag = "admin",
security(("bearer" = [])),
responses(
(status = 200, body = SettingsResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn get_settings( pub async fn get_settings(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -20,6 +31,18 @@ pub async fn get_settings(
Ok(Json(SettingsResponse { settings })) Ok(Json(SettingsResponse { settings }))
} }
#[utoipa::path(
put,
path = "/api/v1/admin/settings",
tag = "admin",
security(("bearer" = [])),
request_body = HashMap<String, String>,
responses(
(status = 200, body = SettingsResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn update_settings( pub async fn update_settings(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -34,6 +57,18 @@ pub async fn update_settings(
Ok(Json(SettingsResponse { settings })) Ok(Json(SettingsResponse { settings }))
} }
#[utoipa::path(
get,
path = "/api/v1/admin/activity",
tag = "admin",
security(("bearer" = [])),
params(ActivityLogParams),
responses(
(status = 200, body = Vec<ActivityEventResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn get_activity_log( pub async fn get_activity_log(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,

View File

@@ -10,6 +10,16 @@ use crate::state::AppState;
const TOKEN_TYPE_BEARER: &str = "Bearer"; const TOKEN_TYPE_BEARER: &str = "Bearer";
#[utoipa::path(
post,
path = "/api/v1/auth/register",
tag = "auth",
request_body = RegisterRequest,
responses(
(status = 200, body = UserResponse),
(status = 409, body = api_types::ErrorResponse),
)
)]
pub async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterRequest>, Json(req): Json<RegisterRequest>,
@@ -22,6 +32,16 @@ pub async fn register(
Ok(Json(UserResponse::from(user))) Ok(Json(UserResponse::from(user)))
} }
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, body = TokenResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<LoginRequest>, Json(req): Json<LoginRequest>,
@@ -40,15 +60,43 @@ pub async fn login(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "auth",
responses(
(status = 200, body = serde_json::Value),
)
)]
pub async fn logout() -> Result<Json<serde_json::Value>, AppError> { pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({"message": "logged out"}))) Ok(Json(serde_json::json!({"message": "logged out"})))
} }
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "auth",
security(("bearer" = [])),
responses(
(status = 200, body = UserResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> { pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> {
Ok(Json(UserResponse::from(user))) Ok(Json(UserResponse::from(user)))
} }
#[cfg(feature = "auth-jwt")] #[cfg(feature = "auth-jwt")]
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
tag = "auth",
request_body = RefreshRequest,
responses(
(status = 200, body = TokenResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn refresh_token( pub async fn refresh_token(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RefreshRequest>, Json(req): Json<RefreshRequest>,

View File

@@ -13,6 +13,16 @@ use crate::errors::AppError;
use crate::extractors::CurrentUser; use crate::extractors::CurrentUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/channels",
tag = "channels",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ChannelResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_channels( pub async fn list_channels(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -21,6 +31,16 @@ pub async fn list_channels(
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/mine",
tag = "channels",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ChannelResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_my_channels( pub async fn list_my_channels(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -29,6 +49,18 @@ pub async fn list_my_channels(
Ok(Json(channels.into_iter().map(ChannelResponse::from).collect())) Ok(Json(channels.into_iter().map(ChannelResponse::from).collect()))
} }
#[utoipa::path(
post,
path = "/api/v1/channels",
tag = "channels",
security(("bearer" = [])),
request_body = CreateChannelRequest,
responses(
(status = 200, body = ChannelResponse),
(status = 400, body = api_types::ErrorResponse),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn create_channel( pub async fn create_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -43,6 +75,20 @@ pub async fn create_channel(
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ChannelResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_channel( pub async fn get_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -56,6 +102,22 @@ pub async fn get_channel(
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
put,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
request_body = UpdateChannelRequest,
responses(
(status = 200, body = ChannelResponse),
(status = 400, body = api_types::ErrorResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn update_channel( pub async fn update_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -77,6 +139,21 @@ pub async fn update_channel(
Ok(Json(ChannelResponse::from(channel))) Ok(Json(ChannelResponse::from(channel)))
} }
#[utoipa::path(
delete,
path = "/api/v1/channels/{id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 204),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn delete_channel( pub async fn delete_channel(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
@@ -90,6 +167,20 @@ pub async fn delete_channel(
Ok(axum::http::StatusCode::NO_CONTENT) Ok(axum::http::StatusCode::NO_CONTENT)
} }
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/snapshots",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn save_snapshot( pub async fn save_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -103,6 +194,20 @@ pub async fn save_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/snapshots",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<ConfigSnapshotResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn list_snapshots( pub async fn list_snapshots(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -112,6 +217,21 @@ pub async fn list_snapshots(
Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect())) Ok(Json(snaps.into_iter().map(ConfigSnapshotResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_snapshot( pub async fn get_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -125,6 +245,22 @@ pub async fn get_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
patch,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
request_body = PatchSnapshotRequest,
responses(
(status = 200, body = ConfigSnapshotResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn patch_snapshot( pub async fn patch_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -140,6 +276,21 @@ pub async fn patch_snapshot(
Ok(Json(ConfigSnapshotResponse::from(snap))) Ok(Json(ConfigSnapshotResponse::from(snap)))
} }
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/snapshots/{snapshot_id}/restore",
tag = "channels",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
("snapshot_id" = uuid::Uuid, Path, description = "Snapshot ID"),
),
responses(
(status = 200, body = ChannelResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn restore_snapshot( pub async fn restore_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,

View File

@@ -7,6 +7,14 @@ use application::config::GetConfigQuery;
use crate::errors::AppError; use crate::errors::AppError;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/config",
tag = "config",
responses(
(status = 200, body = ConfigResponse),
)
)]
pub async fn get_config( pub async fn get_config(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, AppError> { ) -> Result<Json<ConfigResponse>, AppError> {

View File

@@ -12,6 +12,15 @@ use crate::state::AppState;
const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8"; const M3U_CONTENT_TYPE: &str = "audio/x-mpegurl; charset=utf-8";
const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8"; const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
#[utoipa::path(
get,
path = "/api/v1/iptv/playlist.m3u",
tag = "iptv",
params(IptvParams),
responses(
(status = 200, content_type = "audio/x-mpegurl", body = String),
)
)]
pub async fn m3u_playlist( pub async fn m3u_playlist(
State(state): State<AppState>, State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser, OptionalCurrentUser(_user): OptionalCurrentUser,
@@ -25,6 +34,14 @@ pub async fn m3u_playlist(
Ok(([(header::CONTENT_TYPE, M3U_CONTENT_TYPE)], content)) Ok(([(header::CONTENT_TYPE, M3U_CONTENT_TYPE)], content))
} }
#[utoipa::path(
get,
path = "/api/v1/iptv/epg.xml",
tag = "iptv",
responses(
(status = 200, content_type = "application/xml", body = String),
)
)]
pub async fn xmltv_epg( pub async fn xmltv_epg(
State(state): State<AppState>, State(state): State<AppState>,
OptionalCurrentUser(_user): OptionalCurrentUser, OptionalCurrentUser(_user): OptionalCurrentUser,

View File

@@ -15,6 +15,17 @@ use crate::state::AppState;
const DEFAULT_SEARCH_LIMIT: u32 = 50; const DEFAULT_SEARCH_LIMIT: u32 = 50;
#[utoipa::path(
get,
path = "/api/v1/library/items",
tag = "library",
security(("bearer" = [])),
params(LibrarySearchParams),
responses(
(status = 200, body = PaginatedResponse<LibraryItemResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn search_items( pub async fn search_items(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -39,6 +50,20 @@ pub async fn search_items(
))) )))
} }
#[utoipa::path(
get,
path = "/api/v1/library/items/{id}",
tag = "library",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Library item ID"),
),
responses(
(status = 200, body = LibraryItemResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_item( pub async fn get_item(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -52,6 +77,17 @@ pub async fn get_item(
Ok(Json(LibraryItemResponse::from(item))) Ok(Json(LibraryItemResponse::from(item)))
} }
#[utoipa::path(
get,
path = "/api/v1/library/collections",
tag = "library",
security(("bearer" = [])),
params(ProviderParam),
responses(
(status = 200, body = Vec<CollectionResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_collections( pub async fn list_collections(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -69,6 +105,17 @@ pub async fn list_collections(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/library/shows",
tag = "library",
security(("bearer" = [])),
params(ShowsParams),
responses(
(status = 200, body = Vec<ShowResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_shows( pub async fn list_shows(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -85,6 +132,17 @@ pub async fn list_shows(
Ok(Json(shows.into_iter().map(ShowResponse::from).collect())) Ok(Json(shows.into_iter().map(ShowResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/library/seasons",
tag = "library",
security(("bearer" = [])),
params(SeasonsParams),
responses(
(status = 200, body = Vec<SeasonResponse>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_seasons( pub async fn list_seasons(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -99,6 +157,17 @@ pub async fn list_seasons(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/library/genres",
tag = "library",
security(("bearer" = [])),
params(GenresParams),
responses(
(status = 200, body = Vec<String>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_genres( pub async fn list_genres(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -116,6 +185,16 @@ pub async fn list_genres(
Ok(Json(genres)) Ok(Json(genres))
} }
#[utoipa::path(
get,
path = "/api/v1/library/sync/status",
tag = "library",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<SyncStatusEntry>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn sync_status( pub async fn sync_status(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -124,6 +203,17 @@ pub async fn sync_status(
Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect())) Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect()))
} }
#[utoipa::path(
post,
path = "/api/v1/library/sync",
tag = "library",
security(("bearer" = [])),
responses(
(status = 202),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn trigger_sync( pub async fn trigger_sync(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,

View File

@@ -9,6 +9,17 @@ use crate::errors::AppError;
use crate::extractors::AdminUser; use crate::extractors::AdminUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
get,
path = "/api/v1/admin/providers",
tag = "providers",
security(("bearer" = [])),
responses(
(status = 200, body = Vec<ProviderConfigResponse>),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn list_providers( pub async fn list_providers(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -22,6 +33,21 @@ pub async fn list_providers(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
responses(
(status = 200, body = ProviderConfigResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_provider( pub async fn get_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -35,6 +61,21 @@ pub async fn get_provider(
Ok(Json(ProviderConfigResponse::from(provider))) Ok(Json(ProviderConfigResponse::from(provider)))
} }
#[utoipa::path(
put,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
request_body = ProviderConfigRequest,
responses(
(status = 200, body = serde_json::Value),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
)
)]
pub async fn upsert_provider( pub async fn upsert_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,
@@ -51,6 +92,21 @@ pub async fn upsert_provider(
Ok(Json(serde_json::json!({"status": "ok"}))) Ok(Json(serde_json::json!({"status": "ok"})))
} }
#[utoipa::path(
delete,
path = "/api/v1/admin/providers/{id}",
tag = "providers",
security(("bearer" = [])),
params(
("id" = String, Path, description = "Provider ID"),
),
responses(
(status = 204),
(status = 401, body = api_types::ErrorResponse),
(status = 403, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn delete_provider( pub async fn delete_provider(
State(state): State<AppState>, State(state): State<AppState>,
AdminUser(_user): AdminUser, AdminUser(_user): AdminUser,

View File

@@ -1,6 +1,6 @@
use axum::Json; use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::{StatusCode, header}; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use chrono::Utc; use chrono::Utc;
@@ -10,13 +10,26 @@ use api_types::{
use application::schedule::{ use application::schedule::{
GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery, GenerateScheduleCommand, GetCurrentBroadcastQuery, GetEpgQuery, GetSourceQuery,
}; };
use domain::DomainError;
use domain::value_objects::ChannelId; use domain::value_objects::ChannelId;
use crate::errors::AppError; use crate::errors::AppError;
use crate::extractors::CurrentUser; use crate::extractors::CurrentUser;
use crate::state::AppState; use crate::state::AppState;
#[utoipa::path(
post,
path = "/api/v1/channels/{id}/schedule",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ScheduleResponse),
(status = 401, body = api_types::ErrorResponse),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn generate_schedule( pub async fn generate_schedule(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -27,6 +40,20 @@ pub async fn generate_schedule(
Ok(Json(ScheduleResponse::from(schedule))) Ok(Json(ScheduleResponse::from(schedule)))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/schedule",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = ScheduleResponse),
(status = 204),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn get_active_schedule( pub async fn get_active_schedule(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -39,6 +66,19 @@ pub async fn get_active_schedule(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/now",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = CurrentBroadcastResponse),
(status = 204),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_current_broadcast( pub async fn get_current_broadcast(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -58,6 +98,18 @@ pub async fn get_current_broadcast(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/epg",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<SlotResponse>),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_epg( pub async fn get_epg(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -67,6 +119,19 @@ pub async fn get_epg(
Ok(Json(slots.into_iter().map(SlotResponse::from).collect())) Ok(Json(slots.into_iter().map(SlotResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/stream",
tag = "schedule",
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = String),
(status = 204),
(status = 404, body = api_types::ErrorResponse),
)
)]
pub async fn get_stream( pub async fn get_stream(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@@ -78,6 +143,19 @@ pub async fn get_stream(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/channels/{id}/schedule/history",
tag = "schedule",
security(("bearer" = [])),
params(
("id" = uuid::Uuid, Path, description = "Channel ID"),
),
responses(
(status = 200, body = Vec<ScheduleHistoryEntry>),
(status = 401, body = api_types::ErrorResponse),
)
)]
pub async fn list_schedule_history( pub async fn list_schedule_history(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
@@ -92,26 +170,3 @@ pub async fn list_schedule_history(
.collect(), .collect(),
)) ))
} }
pub async fn export_ical(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, AppError> {
let channel = state
.channel_query
.find_by_id(id.into())
.await?
.ok_or_else(|| AppError(DomainError::NotFound(format!("Channel {id} not found"))))?;
let ical = domain::generate_ical(channel.name(), channel.timezone(), channel.schedule_config());
let disposition = format!("attachment; filename=\"{}.ics\"", channel.name());
Ok((
[
(header::CONTENT_TYPE, "text/calendar; charset=utf-8".to_string()),
(header::CONTENT_DISPOSITION, disposition),
],
ical,
)
.into_response())
}

View File

@@ -9,6 +9,7 @@ mod extractors;
mod factory; mod factory;
mod handlers; mod handlers;
mod mappers; mod mappers;
mod openapi;
mod routes; mod routes;
mod state; mod state;
@@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> {
let app = axum::Router::new() let app = axum::Router::new()
.nest("/api/v1", routes::api_v1_router()) .nest("/api/v1", routes::api_v1_router())
.nest("/api", routes::docs_router())
.layer(cors) .layer(cors)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(app_state); .with_state(app_state);

View File

@@ -0,0 +1,124 @@
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
struct BearerAuth;
impl Modify for BearerAuth {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
#[derive(OpenApi)]
#[openapi(
info(
title = "K-TV API",
version = "1.0.0",
description = "Self-hosted linear TV channel orchestration",
),
modifiers(&BearerAuth),
paths(
crate::handlers::auth::register,
crate::handlers::auth::login,
crate::handlers::auth::logout,
crate::handlers::auth::me,
crate::handlers::auth::refresh_token,
crate::handlers::channels::list_channels,
crate::handlers::channels::list_my_channels,
crate::handlers::channels::create_channel,
crate::handlers::channels::get_channel,
crate::handlers::channels::update_channel,
crate::handlers::channels::delete_channel,
crate::handlers::channels::save_snapshot,
crate::handlers::channels::list_snapshots,
crate::handlers::channels::get_snapshot,
crate::handlers::channels::patch_snapshot,
crate::handlers::channels::restore_snapshot,
crate::handlers::schedule::generate_schedule,
crate::handlers::schedule::get_active_schedule,
crate::handlers::schedule::get_current_broadcast,
crate::handlers::schedule::get_epg,
crate::handlers::schedule::get_stream,
crate::handlers::schedule::list_schedule_history,
crate::handlers::admin::get_settings,
crate::handlers::admin::update_settings,
crate::handlers::admin::get_activity_log,
crate::handlers::providers::list_providers,
crate::handlers::providers::get_provider,
crate::handlers::providers::upsert_provider,
crate::handlers::providers::delete_provider,
crate::handlers::config::get_config,
crate::handlers::iptv::m3u_playlist,
crate::handlers::iptv::xmltv_epg,
crate::handlers::library::search_items,
crate::handlers::library::get_item,
crate::handlers::library::list_collections,
crate::handlers::library::list_shows,
crate::handlers::library::list_seasons,
crate::handlers::library::list_genres,
crate::handlers::library::sync_status,
crate::handlers::library::trigger_sync,
),
components(schemas(
api_types::LoginRequest,
api_types::RegisterRequest,
api_types::RefreshRequest,
api_types::TokenResponse,
api_types::UserResponse,
api_types::ChannelResponse,
api_types::CreateChannelRequest,
api_types::UpdateChannelRequest,
api_types::ConfigSnapshotResponse,
api_types::PatchSnapshotRequest,
api_types::ScheduleResponse,
api_types::SlotResponse,
api_types::MediaItemResponse,
api_types::CurrentBroadcastResponse,
api_types::ScheduleHistoryEntry,
api_types::SettingsResponse,
api_types::ActivityEventResponse,
api_types::ActivityLogParams,
api_types::ProviderConfigRequest,
api_types::ProviderConfigResponse,
api_types::ConfigResponse,
api_types::ProviderCapabilitiesResponse,
api_types::ProviderInfo,
api_types::IptvParams,
api_types::LibraryItemResponse,
api_types::CollectionResponse,
api_types::ShowResponse,
api_types::SeasonResponse,
api_types::SyncStatusEntry,
api_types::LibrarySearchParams,
api_types::ProviderParam,
api_types::ShowsParams,
api_types::SeasonsParams,
api_types::GenresParams,
api_types::PaginatedResponse<api_types::LibraryItemResponse>,
api_types::ErrorResponse,
)),
security(
("bearer" = []),
),
tags(
(name = "auth", description = "Authentication"),
(name = "channels", description = "Channel management"),
(name = "schedule", description = "Schedule generation and playback"),
(name = "admin", description = "Admin settings and activity"),
(name = "providers", description = "Media provider configuration"),
(name = "config", description = "Public system configuration"),
(name = "iptv", description = "IPTV playlist and EPG feeds"),
(name = "library", description = "Media library browsing and sync"),
),
)]
pub struct ApiDoc;

View File

@@ -1,6 +1,9 @@
use axum::{Router, routing::{delete, get, post, put}}; use axum::{Json, Router, routing::{delete, get, post, put}};
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use crate::handlers; use crate::handlers;
use crate::openapi::ApiDoc;
use crate::state::AppState; use crate::state::AppState;
pub fn api_v1_router() -> Router<AppState> { pub fn api_v1_router() -> Router<AppState> {
@@ -15,6 +18,12 @@ pub fn api_v1_router() -> Router<AppState> {
.merge(local_files_router()) .merge(local_files_router())
} }
pub fn docs_router() -> Router<AppState> {
Router::new()
.route("/docs", get(|| async { Json(ApiDoc::openapi()) }))
.merge(Scalar::with_url("/docs/ui", ApiDoc::openapi()))
}
fn auth_router() -> Router<AppState> { fn auth_router() -> Router<AppState> {
let r = Router::new() let r = Router::new()
.route("/register", post(handlers::auth::register)) .route("/register", post(handlers::auth::register))
@@ -44,7 +53,6 @@ fn channel_router() -> Router<AppState> {
.route("/{id}/now", get(handlers::schedule::get_current_broadcast)) .route("/{id}/now", get(handlers::schedule::get_current_broadcast))
.route("/{id}/epg", get(handlers::schedule::get_epg)) .route("/{id}/epg", get(handlers::schedule::get_epg))
.route("/{id}/stream", get(handlers::schedule::get_stream)) .route("/{id}/stream", get(handlers::schedule::get_stream))
.route("/{id}/export.ics", get(handlers::schedule::export_ical))
.route("/{id}/snapshots", post(handlers::channels::save_snapshot)) .route("/{id}/snapshots", post(handlers::channels::save_snapshot))
.route("/{id}/snapshots", get(handlers::channels::list_snapshots)) .route("/{id}/snapshots", get(handlers::channels::list_snapshots))
.route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot)) .route("/{id}/snapshots/{snapshot_id}", get(handlers::channels::get_snapshot))