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 `
+ {capabilities?.rescan && (
+
+ )}
{channels && channels.length > 0 && (
void;
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
/** Called when the browser blocks autoplay and user interaction is required. */
@@ -34,6 +36,7 @@ const VideoPlayer = forwardRef(
initialOffset = 0,
subtitleTrack = -1,
muted = false,
+ streamingProtocol,
onStreamError,
onSubtitleTracksChange,
onNeedsInteraction,
@@ -75,7 +78,7 @@ const VideoPlayer = forwardRef(
onSubtitleTracksChange?.([]);
setIsBuffering(true);
- const isHls = src.includes(".m3u8");
+ const isHls = streamingProtocol !== "direct_file" && src.includes(".m3u8");
if (isHls && Hls.isSupported()) {
const hls = new Hls({
@@ -117,10 +120,18 @@ const VideoPlayer = forwardRef(
{ once: true },
);
} else {
- // Plain MP4 fallback
+ // Plain MP4 / direct file: seek to offset after metadata loads.
video.src = src;
+ video.addEventListener(
+ "loadedmetadata",
+ () => {
+ if (initialOffset > 0) video.currentTime = initialOffset;
+ video.muted = mutedRef.current;
+ video.play().catch(() => onNeedsInteraction?.());
+ },
+ { once: true },
+ );
video.load();
- video.play().catch(() => {});
}
return () => {
diff --git a/k-tv-frontend/hooks/use-config.ts b/k-tv-frontend/hooks/use-config.ts
new file mode 100644
index 0000000..d8cc87d
--- /dev/null
+++ b/k-tv-frontend/hooks/use-config.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import type { ConfigResponse } from "@/lib/types";
+
+export function useConfig() {
+ return useQuery({
+ queryKey: ["config"],
+ queryFn: () => api.config.get(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
diff --git a/k-tv-frontend/hooks/use-library.ts b/k-tv-frontend/hooks/use-library.ts
index db5dbed..fe41060 100644
--- a/k-tv-frontend/hooks/use-library.ts
+++ b/k-tv-frontend/hooks/use-library.ts
@@ -1,6 +1,6 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useAuthContext } from "@/context/auth-context";
import type { MediaFilter } from "@/lib/types";
@@ -23,27 +23,39 @@ export function useCollections() {
* All series are loaded upfront so the series picker can filter client-side
* without a request per keystroke.
*/
-export function useSeries(collectionId?: string) {
+export function useSeries(collectionId?: string, opts?: { enabled?: boolean }) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "series", collectionId ?? null],
queryFn: () => api.library.series(token!, collectionId),
- enabled: !!token,
+ enabled: !!token && (opts?.enabled ?? true),
staleTime: STALE,
});
}
/** List available genres, optionally scoped to a content type. */
-export function useGenres(contentType?: string) {
+export function useGenres(contentType?: string, opts?: { enabled?: boolean }) {
const { token } = useAuthContext();
return useQuery({
queryKey: ["library", "genres", contentType ?? null],
queryFn: () => api.library.genres(token!, contentType),
- enabled: !!token,
+ enabled: !!token && (opts?.enabled ?? true),
staleTime: STALE,
});
}
+/** Trigger a local-files rescan. Only available when `provider_capabilities.rescan` is true. */
+export function useRescanLibrary() {
+ const { token } = useAuthContext();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.files.rescan(token!),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["library"] });
+ },
+ });
+}
+
/**
* Fetch items matching a filter for the block editor's "Preview results" panel.
* Pass `enabled: false` until the user explicitly requests a preview.
diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts
index 59fdd9f..acebc96 100644
--- a/k-tv-frontend/lib/api.ts
+++ b/k-tv-frontend/lib/api.ts
@@ -142,6 +142,11 @@ export const api = {
},
},
+ files: {
+ rescan: (token: string) =>
+ request<{ items_found: number }>("/files/rescan", { method: "POST", token }),
+ },
+
schedule: {
generate: (channelId: string, token: string) =>
request(`/channels/${channelId}/schedule`, {
diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts
index 0381d1b..49ba5d8 100644
--- a/k-tv-frontend/lib/types.ts
+++ b/k-tv-frontend/lib/types.ts
@@ -82,8 +82,22 @@ export interface ScheduleConfig {
// Config
+export type StreamingProtocol = "hls" | "direct_file";
+
+export interface ProviderCapabilities {
+ collections: boolean;
+ series: boolean;
+ genres: boolean;
+ tags: boolean;
+ decade: boolean;
+ search: boolean;
+ streaming_protocol: StreamingProtocol;
+ rescan: boolean;
+}
+
export interface ConfigResponse {
allow_registration: boolean;
+ provider_capabilities: ProviderCapabilities;
}
// Auth