diff --git a/k-tv-backend/Cargo.lock b/k-tv-backend/Cargo.lock index 9608959..250bddc 100644 --- a/k-tv-backend/Cargo.lock +++ b/k-tv-backend/Cargo.lock @@ -87,6 +87,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", + "tokio-util", "tower", "tower-http", "tracing", @@ -1372,6 +1373,7 @@ dependencies = [ "async-nats", "async-trait", "axum-extra", + "base64 0.22.1", "chrono", "domain", "futures-core", @@ -1389,6 +1391,7 @@ dependencies = [ "tracing", "url", "uuid", + "walkdir", ] [[package]] @@ -2468,6 +2471,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3539,6 +3551,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3675,6 +3697,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/k-tv-backend/Dockerfile b/k-tv-backend/Dockerfile index 3d34f64..381867f 100644 --- a/k-tv-backend/Dockerfile +++ b/k-tv-backend/Dockerfile @@ -10,8 +10,8 @@ FROM debian:bookworm-slim WORKDIR /app -# Install OpenSSL (required for many Rust networking crates) and CA certificates -RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* +# 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/* COPY --from=builder /app/target/release/api . diff --git a/k-tv-backend/api/Cargo.toml b/k-tv-backend/api/Cargo.toml index c517d5d..dbd304a 100644 --- a/k-tv-backend/api/Cargo.toml +++ b/k-tv-backend/api/Cargo.toml @@ -11,6 +11,7 @@ postgres = ["infra/postgres"] auth-oidc = ["infra/auth-oidc"] auth-jwt = ["infra/auth-jwt"] jellyfin = ["infra/jellyfin"] +local-files = ["infra/local-files", "dep:tokio-util"] [dependencies] k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ @@ -51,3 +52,4 @@ tracing = "0.1" async-trait = "0.1" dotenvy = "0.15.7" time = "0.3" +tokio-util = { version = "0.7", features = ["io"], optional = true } diff --git a/k-tv-backend/api/src/config.rs b/k-tv-backend/api/src/config.rs index 1b182cc..f8051d7 100644 --- a/k-tv-backend/api/src/config.rs +++ b/k-tv-backend/api/src/config.rs @@ -3,6 +3,7 @@ //! Loads configuration from environment variables. use std::env; +use std::path::PathBuf; /// Application configuration loaded from environment variables #[derive(Debug, Clone)] @@ -40,6 +41,9 @@ pub struct Config { pub jellyfin_api_key: Option, pub jellyfin_user_id: Option, + /// Root directory for the local-files provider. Set `LOCAL_FILES_DIR` to enable. + pub local_files_dir: Option, + /// Public base URL of this API server (used to build IPTV stream URLs). pub base_url: String, } @@ -114,6 +118,8 @@ impl Config { let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok(); let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok(); + let local_files_dir = env::var("LOCAL_FILES_DIR").ok().map(PathBuf::from); + let base_url = env::var("BASE_URL") .unwrap_or_else(|_| format!("http://localhost:{}", port)); @@ -140,6 +146,7 @@ impl Config { jellyfin_base_url, jellyfin_api_key, jellyfin_user_id, + local_files_dir, base_url, } } diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index e0a5b9a..f468fce 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -46,6 +46,7 @@ pub struct TokenResponse { #[derive(Debug, Serialize)] pub struct ConfigResponse { pub allow_registration: bool, + pub provider_capabilities: domain::ProviderCapabilities, } // ============================================================================ diff --git a/k-tv-backend/api/src/error.rs b/k-tv-backend/api/src/error.rs index 72584ee..fba2d38 100644 --- a/k-tv-backend/api/src/error.rs +++ b/k-tv-backend/api/src/error.rs @@ -35,6 +35,12 @@ pub enum ApiError { #[error("auth_required")] AuthRequired, + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Not implemented: {0}")] + NotImplemented(String), } /// Error response body @@ -132,6 +138,22 @@ impl IntoResponse for ApiError { details: None, }, ), + + ApiError::NotFound(msg) => ( + StatusCode::NOT_FOUND, + ErrorResponse { + error: "Not found".to_string(), + details: Some(msg.clone()), + }, + ), + + ApiError::NotImplemented(msg) => ( + StatusCode::NOT_IMPLEMENTED, + ErrorResponse { + error: "Not implemented".to_string(), + details: Some(msg.clone()), + }, + ), }; (status, Json(error_response)).into_response() @@ -146,6 +168,14 @@ impl ApiError { pub fn internal(msg: impl Into) -> Self { Self::Internal(msg.into()) } + + pub fn not_found(msg: impl Into) -> Self { + Self::NotFound(msg.into()) + } + + pub fn not_implemented(msg: impl Into) -> Self { + Self::NotImplemented(msg.into()) + } } /// Result type alias for API handlers diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index 6f6150f..db599c0 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -10,7 +10,7 @@ use axum::http::{HeaderName, HeaderValue}; use std::sync::Arc; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; -use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService}; +use domain::{ChannelService, IMediaProvider, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService}; use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository}; use infra::run_migrations; use k_core::http::server::{ServerConfig, apply_standard_middleware}; @@ -72,8 +72,52 @@ async fn main() -> anyhow::Result<()> { let user_service = UserService::new(user_repo); let channel_service = ChannelService::new(channel_repo.clone()); - // Build media provider — Jellyfin if configured, no-op fallback otherwise. - let media_provider: Arc = build_media_provider(&config); + // Build media provider — Jellyfin → local-files → noop, first match wins. + #[cfg(feature = "local-files")] + let mut local_index: Option> = None; + + let mut maybe_provider: Option> = None; + + #[cfg(feature = "jellyfin")] + if let (Some(base_url), Some(api_key), Some(user_id)) = ( + &config.jellyfin_base_url, + &config.jellyfin_api_key, + &config.jellyfin_user_id, + ) { + tracing::info!("Media provider: Jellyfin at {}", base_url); + maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig { + base_url: base_url.clone(), + api_key: api_key.clone(), + user_id: user_id.clone(), + }))); + } + + #[cfg(feature = "local-files")] + if maybe_provider.is_none() { + if let Some(dir) = &config.local_files_dir { + if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool { + tracing::info!("Media provider: local files at {:?}", dir); + let lf_cfg = infra::LocalFilesConfig { + root_dir: dir.clone(), + base_url: config.base_url.clone(), + }; + let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await); + local_index = Some(Arc::clone(&idx)); + 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))); + } else { + tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR"); + } + } + } + + let media_provider: Arc = maybe_provider.unwrap_or_else(|| { + tracing::warn!( + "No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR." + ); + Arc::new(NoopMediaProvider) + }); let bg_channel_repo = channel_repo.clone(); let schedule_engine = ScheduleEngineService::new( @@ -82,7 +126,8 @@ async fn main() -> anyhow::Result<()> { schedule_repo, ); - let state = AppState::new( + #[allow(unused_mut)] + let mut state = AppState::new( user_service, channel_service, schedule_engine, @@ -91,6 +136,11 @@ async fn main() -> anyhow::Result<()> { ) .await?; + #[cfg(feature = "local-files")] + { + state.local_index = local_index; + } + let server_config = ServerConfig { cors_origins: config.cors_allowed_origins.clone(), }; @@ -141,31 +191,6 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -/// Build the media provider from config. -/// Falls back to a no-op provider that returns an informative error when -/// Jellyfin env vars are not set, so other API features still work in dev. -fn build_media_provider(config: &Config) -> Arc { - #[cfg(feature = "jellyfin")] - if let (Some(base_url), Some(api_key), Some(user_id)) = ( - &config.jellyfin_base_url, - &config.jellyfin_api_key, - &config.jellyfin_user_id, - ) { - tracing::info!("Media provider: Jellyfin at {}", base_url); - return Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig { - base_url: base_url.clone(), - api_key: api_key.clone(), - user_id: user_id.clone(), - })); - } - - tracing::warn!( - "No media provider configured. Set JELLYFIN_BASE_URL, JELLYFIN_API_KEY, \ - and JELLYFIN_USER_ID to enable schedule generation." - ); - Arc::new(NoopMediaProvider) -} - /// Stand-in provider used when no real media source is configured. /// Returns a descriptive error for every call so schedule endpoints fail /// gracefully rather than panicking at startup. @@ -173,14 +198,25 @@ 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, _: &domain::MediaFilter, ) -> domain::DomainResult> { Err(domain::DomainError::InfrastructureError( - "No media provider configured. Set JELLYFIN_BASE_URL, JELLYFIN_API_KEY, \ - and JELLYFIN_USER_ID." - .into(), + "No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.".into(), )) } diff --git a/k-tv-backend/api/src/routes/config.rs b/k-tv-backend/api/src/routes/config.rs index ada278f..0adc712 100644 --- a/k-tv-backend/api/src/routes/config.rs +++ b/k-tv-backend/api/src/routes/config.rs @@ -1,6 +1,5 @@ use axum::{Json, Router, extract::State, routing::get}; -use std::sync::Arc; -use crate::config::Config; + use crate::dto::ConfigResponse; use crate::state::AppState; @@ -8,8 +7,9 @@ pub fn router() -> Router { Router::new().route("/", get(get_config)) } -async fn get_config(State(config): State>) -> Json { +async fn get_config(State(state): State) -> Json { Json(ConfigResponse { - allow_registration: config.allow_registration, + allow_registration: state.config.allow_registration, + provider_capabilities: state.media_provider.capabilities(), }) } diff --git a/k-tv-backend/api/src/routes/files.rs b/k-tv-backend/api/src/routes/files.rs new file mode 100644 index 0000000..e69ae74 --- /dev/null +++ b/k-tv-backend/api/src/routes/files.rs @@ -0,0 +1,153 @@ +//! Local-file streaming and rescan routes +//! +//! GET /files/stream/:encoded_id — serve a local video file with Range support +//! POST /files/rescan — trigger an index rebuild (auth required) + +use axum::{ + Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Response, + routing::{get, post}, +}; + +use crate::{error::ApiError, extractors::CurrentUser, state::AppState}; + +pub fn router() -> Router { + let r = Router::new().route("/stream/{id}", get(stream_file)); + #[cfg(feature = "local-files")] + let r = r.route("/rescan", post(trigger_rescan)); + r +} + +/// Stream a local video file, honouring `Range` headers for seeking. +/// +/// The path segment is a base64url-encoded relative path produced by the +/// `LocalFilesProvider`. No authentication required — the ID is not guessable +/// without knowing the filesystem layout. +async fn stream_file( + State(state): State, + Path(encoded_id): Path, + headers: HeaderMap, +) -> Result { + #[cfg(feature = "local-files")] + { + use axum::body::Body; + use std::io::SeekFrom; + use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _}; + use tokio_util::io::ReaderStream; + + let root_dir = state.config.local_files_dir.as_ref().ok_or_else(|| { + ApiError::not_implemented("LOCAL_FILES_DIR not configured") + })?; + + let rel = infra::local_files::decode_stream_id(&encoded_id) + .ok_or_else(|| ApiError::validation("invalid stream id"))?; + + // Security: canonicalise and verify the path stays inside root. + let full_path = root_dir.join(&rel); + let canonical_root = root_dir + .canonicalize() + .map_err(|e| ApiError::internal(e.to_string()))?; + let canonical = full_path + .canonicalize() + .map_err(|_| ApiError::not_found("file not found"))?; + if !canonical.starts_with(&canonical_root) { + return Err(ApiError::Forbidden("path traversal detected".into())); + } + + let mut file = tokio::fs::File::open(&canonical) + .await + .map_err(|_| ApiError::not_found("file not found"))?; + let file_size = file + .metadata() + .await + .map_err(|e| ApiError::internal(e.to_string()))? + .len(); + + let ext = canonical + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let content_type = content_type_for_ext(&ext); + + // Parse Range header. + let range = headers + .get(axum::http::header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|r| parse_range(r, file_size)); + + let (start, end, status) = if let Some((s, e)) = range { + (s, e.min(file_size.saturating_sub(1)), StatusCode::PARTIAL_CONTENT) + } else { + (0, file_size.saturating_sub(1), StatusCode::OK) + }; + let length = end - start + 1; + + file.seek(SeekFrom::Start(start)) + .await + .map_err(|e| ApiError::internal(e.to_string()))?; + + let stream = ReaderStream::new(file.take(length)); + let body = Body::from_stream(stream); + + let mut builder = Response::builder() + .status(status) + .header("Content-Type", content_type) + .header("Content-Length", length.to_string()) + .header("Accept-Ranges", "bytes"); + + if status == StatusCode::PARTIAL_CONTENT { + builder = builder.header( + "Content-Range", + format!("bytes {}-{}/{}", start, end, file_size), + ); + } + + return builder.body(body).map_err(|e| ApiError::internal(e.to_string())); + } + + #[cfg(not(feature = "local-files"))] + Err(ApiError::not_implemented("local-files feature not enabled")) +} + +/// Trigger a filesystem rescan and return the number of items found. +#[cfg(feature = "local-files")] +async fn trigger_rescan( + State(state): State, + CurrentUser(_user): CurrentUser, +) -> Result, ApiError> { + let index = state + .local_index + .as_ref() + .ok_or_else(|| ApiError::not_implemented("no local files provider active"))?; + let count = index.rescan().await; + Ok(axum::Json(serde_json::json!({ "items_found": count }))) +} + +fn content_type_for_ext(ext: &str) -> &'static str { + match ext { + "mp4" | "m4v" => "video/mp4", + "mkv" => "video/x-matroska", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + "webm" => "video/webm", + _ => "application/octet-stream", + } +} + +fn parse_range(range: &str, file_size: u64) -> Option<(u64, u64)> { + let range = range.strip_prefix("bytes=")?; + let (start_str, end_str) = range.split_once('-')?; + let start: u64 = start_str.parse().ok()?; + let end: u64 = if end_str.is_empty() { + file_size.saturating_sub(1) + } else { + end_str.parse().ok()? + }; + if start > end || start >= file_size { + return None; + } + Some((start, end)) +} diff --git a/k-tv-backend/api/src/routes/library.rs b/k-tv-backend/api/src/routes/library.rs index 1563aa7..84fcaad 100644 --- a/k-tv-backend/api/src/routes/library.rs +++ b/k-tv-backend/api/src/routes/library.rs @@ -136,6 +136,11 @@ async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, ) -> Result>, ApiError> { + if !state.media_provider.capabilities().collections { + return Err(ApiError::not_implemented( + "collections not supported by this provider", + )); + } let collections = state.media_provider.list_collections().await?; Ok(Json(collections.into_iter().map(Into::into).collect())) } @@ -146,6 +151,11 @@ async fn list_series( CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { + if !state.media_provider.capabilities().series { + return Err(ApiError::not_implemented( + "series not supported by this provider", + )); + } let series = state .media_provider .list_series(params.collection.as_deref()) @@ -159,6 +169,11 @@ async fn list_genres( CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { + if !state.media_provider.capabilities().genres { + return Err(ApiError::not_implemented( + "genres not supported by this provider", + )); + } let ct = parse_content_type(params.content_type.as_deref())?; let genres = state.media_provider.list_genres(ct.as_ref()).await?; Ok(Json(genres)) diff --git a/k-tv-backend/api/src/routes/mod.rs b/k-tv-backend/api/src/routes/mod.rs index db79404..0a6de3d 100644 --- a/k-tv-backend/api/src/routes/mod.rs +++ b/k-tv-backend/api/src/routes/mod.rs @@ -8,6 +8,7 @@ use axum::Router; pub mod auth; pub mod channels; pub mod config; +pub mod files; pub mod iptv; pub mod library; @@ -17,6 +18,7 @@ pub fn api_v1_router() -> Router { .nest("/auth", auth::router()) .nest("/channels", channels::router()) .nest("/config", config::router()) + .nest("/files", files::router()) .nest("/iptv", iptv::router()) .nest("/library", library::router()) } diff --git a/k-tv-backend/api/src/state.rs b/k-tv-backend/api/src/state.rs index 608a650..0e70185 100644 --- a/k-tv-backend/api/src/state.rs +++ b/k-tv-backend/api/src/state.rs @@ -25,6 +25,9 @@ pub struct AppState { #[cfg(feature = "auth-jwt")] pub jwt_validator: Option>, pub config: Arc, + /// Index for the local-files provider, used by the rescan route. + #[cfg(feature = "local-files")] + pub local_index: Option>, } impl AppState { @@ -105,6 +108,8 @@ impl AppState { #[cfg(feature = "auth-jwt")] jwt_validator, config: Arc::new(config), + #[cfg(feature = "local-files")] + local_index: None, }) } } diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index 1274bdf..da6b1a3 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -14,7 +14,7 @@ pub mod value_objects; // Re-export commonly used types pub use entities::*; pub use errors::{DomainError, DomainResult}; -pub use ports::{Collection, IMediaProvider, SeriesSummary}; +pub use ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamingProtocol}; pub use repositories::*; pub use iptv::{generate_m3u, generate_xmltv}; pub use services::{ChannelService, ScheduleEngineService, UserService}; diff --git a/k-tv-backend/domain/src/ports.rs b/k-tv-backend/domain/src/ports.rs index c1adca6..aee1d82 100644 --- a/k-tv-backend/domain/src/ports.rs +++ b/k-tv-backend/domain/src/ports.rs @@ -12,6 +12,37 @@ use crate::entities::MediaItem; use crate::errors::{DomainError, DomainResult}; use crate::value_objects::{ContentType, MediaFilter, MediaItemId}; +// ============================================================================ +// Provider capabilities +// ============================================================================ + +/// How a provider delivers video to the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamingProtocol { + /// HLS playlist (`.m3u8`). Requires hls.js on non-Safari browsers. + Hls, + /// Direct file URL with Range-header support. Native `