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:
153
k-tv-backend/api/src/routes/files.rs
Normal file
153
k-tv-backend/api/src/routes/files.rs
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user