feat: add local files provider with indexing and rescan functionality

- Implemented LocalFilesProvider to manage local video files.
- Added LocalIndex for in-memory and SQLite-backed indexing of video files.
- Introduced scanning functionality to detect video files and extract metadata.
- Added API endpoints for listing collections, genres, and series based on provider capabilities.
- Enhanced existing routes to check for provider capabilities before processing requests.
- Updated frontend to utilize provider capabilities for conditional rendering of UI elements.
- Implemented rescan functionality to refresh the local files index.
- Added database migration for local files index schema.
This commit is contained in:
2026-03-14 03:44:32 +01:00
parent 9b6bcfc566
commit 8f42164bce
30 changed files with 1033 additions and 59 deletions

View File

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

View File

@@ -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<String>,
pub jellyfin_user_id: Option<String>,
/// Root directory for the local-files provider. Set `LOCAL_FILES_DIR` to enable.
pub local_files_dir: Option<PathBuf>,
/// 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,
}
}

View File

@@ -46,6 +46,7 @@ pub struct TokenResponse {
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
pub provider_capabilities: domain::ProviderCapabilities,
}
// ============================================================================

View File

@@ -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<String>) -> Self {
Self::Internal(msg.into())
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::NotFound(msg.into())
}
pub fn not_implemented(msg: impl Into<String>) -> Self {
Self::NotImplemented(msg.into())
}
}
/// Result type alias for API handlers

View File

@@ -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<dyn IMediaProvider> = build_media_provider(&config);
// Build media provider — Jellyfin → local-files → noop, first match wins.
#[cfg(feature = "local-files")]
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = 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<dyn IMediaProvider> = 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<dyn IMediaProvider> {
#[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<Vec<domain::MediaItem>> {
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(),
))
}

View File

@@ -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<AppState> {
Router::new().route("/", get(get_config))
}
async fn get_config(State(config): State<Arc<Config>>) -> Json<ConfigResponse> {
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
Json(ConfigResponse {
allow_registration: config.allow_registration,
allow_registration: state.config.allow_registration,
provider_capabilities: state.media_provider.capabilities(),
})
}

View File

@@ -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<AppState> {
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<AppState>,
Path(encoded_id): Path<String>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
#[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<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<axum::Json<serde_json::Value>, 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))
}

View File

@@ -136,6 +136,11 @@ async fn list_collections(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<Vec<CollectionResponse>>, 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<SeriesQuery>,
) -> Result<Json<Vec<SeriesResponse>>, 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<GenresQuery>,
) -> Result<Json<Vec<String>>, 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))

View File

@@ -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<AppState> {
.nest("/auth", auth::router())
.nest("/channels", channels::router())
.nest("/config", config::router())
.nest("/files", files::router())
.nest("/iptv", iptv::router())
.nest("/library", library::router())
}

View File

@@ -25,6 +25,9 @@ pub struct AppState {
#[cfg(feature = "auth-jwt")]
pub jwt_validator: Option<Arc<JwtValidator>>,
pub config: Arc<Config>,
/// Index for the local-files provider, used by the rescan route.
#[cfg(feature = "local-files")]
pub local_index: Option<Arc<infra::LocalIndex>>,
}
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,
})
}
}