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

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