Files
k-tv/k-tv-backend/api/src/routes/files.rs
Gabriel Kaszewski 311fdd4006 feat: multi-instance provider support
- provider_configs: add id TEXT PK; migrate existing rows (provider_type becomes id)
- local_files_index: add provider_id column + index; scope all queries per instance
- ProviderConfigRow: add id field; add get_by_id to trait
- LocalIndex:🆕 add provider_id param; all SQL scoped by provider_id
- factory: thread provider_id through build_local_files_bundle
- AppState.local_index: Option<Arc<LocalIndex>> → HashMap<String, Arc<LocalIndex>>
- admin_providers: restructured routes (POST /admin/providers create, PUT/DELETE /{id}, POST /test)
- admin_providers: use row.id as registry key for jellyfin and local_files
- files.rescan: optional ?provider=<id> query param
- frontend: add id to ProviderConfig, update api/hooks, new multi-instance panel UX
2026-03-19 22:54:41 +01:00

360 lines
12 KiB
Rust

//! Local-file streaming, rescan, and transcode routes.
//!
//! GET /files/stream/:id — Range streaming (no auth)
//! POST /files/rescan — index rebuild (auth required)
//! GET /files/transcode/:id/playlist.m3u8 — trigger transcode + serve playlist
//! GET /files/transcode/:id/:segment — serve .ts / .m3u8 segment
//! GET /files/transcode-settings — read TTL (auth)
//! PUT /files/transcode-settings — update TTL (auth)
//! GET /files/transcode-stats — cache size (auth)
//! DELETE /files/transcode-cache — clear cache (auth)
use axum::{
Router,
extract::{Path, State},
http::HeaderMap,
response::Response,
routing::get,
};
use crate::{error::ApiError, state::AppState};
#[cfg(feature = "local-files")]
use axum::{
Json,
extract::Query,
http::StatusCode,
routing::{delete, post},
};
#[cfg(feature = "local-files")]
use serde::Deserialize;
#[cfg(feature = "local-files")]
use crate::{
dto::{TranscodeSettingsResponse, TranscodeStatsResponse, UpdateTranscodeSettingsRequest},
extractors::CurrentUser,
};
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))
.route("/transcode/{id}/playlist.m3u8", get(transcode_playlist))
.route("/transcode/{id}/{segment}", get(transcode_segment))
.route(
"/transcode-settings",
get(get_transcode_settings).put(update_transcode_settings),
)
.route("/transcode-stats", get(get_transcode_stats))
.route("/transcode-cache", delete(clear_transcode_cache));
r
}
// ============================================================================
// Direct streaming
// ============================================================================
#[cfg_attr(not(feature = "local-files"), allow(unused_variables))]
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"))?;
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);
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),
);
}
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"))
}
// ============================================================================
// Rescan
// ============================================================================
#[cfg(feature = "local-files")]
#[derive(Deserialize)]
struct RescanQuery {
provider: Option<String>,
}
#[cfg(feature = "local-files")]
async fn trigger_rescan(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(query): Query<RescanQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let map = state.local_index.read().await.clone();
let index = if let Some(id) = &query.provider {
map.get(id).cloned()
} else {
map.values().next().cloned()
};
let index = index.ok_or_else(|| ApiError::not_implemented("no local files provider active"))?;
let count = index.rescan().await;
Ok(Json(serde_json::json!({ "items_found": count })))
}
// ============================================================================
// Transcode endpoints
// ============================================================================
#[cfg(feature = "local-files")]
async fn transcode_playlist(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let tm = state.transcode_manager.read().await.clone()
.ok_or_else(|| ApiError::not_implemented("TRANSCODE_DIR not configured"))?;
let root = 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(&id)
.ok_or_else(|| ApiError::validation("invalid item id"))?;
let src = root.join(&rel);
tm.ensure_transcoded(&id, &src)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
let playlist_path = tm.transcode_dir.join(&id).join("playlist.m3u8");
let content = tokio::fs::read_to_string(&playlist_path)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Response::builder()
.status(200)
.header("Content-Type", "application/vnd.apple.mpegurl")
.header("Cache-Control", "no-cache")
.body(axum::body::Body::from(content))
.map_err(|e| ApiError::internal(e.to_string()))
}
#[derive(Deserialize)]
#[cfg(feature = "local-files")]
struct TranscodeSegmentPath {
id: String,
segment: String,
}
#[cfg(feature = "local-files")]
async fn transcode_segment(
State(state): State<AppState>,
Path(params): Path<TranscodeSegmentPath>,
) -> Result<Response, ApiError> {
let TranscodeSegmentPath { id, segment } = params;
let ext = std::path::Path::new(&segment)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if ext != "ts" && ext != "m3u8" {
return Err(ApiError::not_found("invalid segment extension"));
}
if segment.contains('/') || segment.contains("..") {
return Err(ApiError::Forbidden("invalid segment path".into()));
}
let tm = state.transcode_manager.read().await.clone()
.ok_or_else(|| ApiError::not_implemented("TRANSCODE_DIR not configured"))?;
let file_path = tm.transcode_dir.join(&id).join(&segment);
let canonical_base = tm
.transcode_dir
.canonicalize()
.map_err(|e| ApiError::internal(e.to_string()))?;
let canonical_file = file_path
.canonicalize()
.map_err(|_| ApiError::not_found("segment not found"))?;
if !canonical_file.starts_with(&canonical_base) {
return Err(ApiError::Forbidden("path traversal detected".into()));
}
let content = tokio::fs::read(&canonical_file)
.await
.map_err(|_| ApiError::not_found("segment not found"))?;
let content_type = if ext == "ts" {
"video/mp2t"
} else {
"application/vnd.apple.mpegurl"
};
Response::builder()
.status(200)
.header("Content-Type", content_type)
.body(axum::body::Body::from(content))
.map_err(|e| ApiError::internal(e.to_string()))
}
// ============================================================================
// Transcode settings / stats / cache management
// ============================================================================
#[cfg(feature = "local-files")]
async fn get_transcode_settings(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<TranscodeSettingsResponse>, ApiError> {
let tm = state.transcode_manager.read().await.clone()
.ok_or_else(|| ApiError::not_implemented("TRANSCODE_DIR not configured"))?;
Ok(Json(TranscodeSettingsResponse {
cleanup_ttl_hours: tm.get_cleanup_ttl(),
}))
}
#[cfg(feature = "local-files")]
async fn update_transcode_settings(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Json(req): Json<UpdateTranscodeSettingsRequest>,
) -> Result<Json<TranscodeSettingsResponse>, ApiError> {
if let Some(repo) = &state.transcode_settings_repo {
repo.save_cleanup_ttl(req.cleanup_ttl_hours)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
}
let tm_opt = state.transcode_manager.read().await.clone();
if let Some(tm) = tm_opt {
tm.set_cleanup_ttl(req.cleanup_ttl_hours);
}
Ok(Json(TranscodeSettingsResponse {
cleanup_ttl_hours: req.cleanup_ttl_hours,
}))
}
#[cfg(feature = "local-files")]
async fn get_transcode_stats(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<Json<TranscodeStatsResponse>, ApiError> {
let tm = state.transcode_manager.read().await.clone()
.ok_or_else(|| ApiError::not_implemented("TRANSCODE_DIR not configured"))?;
let (cache_size_bytes, item_count) = tm.cache_stats().await;
Ok(Json(TranscodeStatsResponse {
cache_size_bytes,
item_count,
}))
}
#[cfg(feature = "local-files")]
async fn clear_transcode_cache(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<StatusCode, ApiError> {
let tm = state.transcode_manager.read().await.clone()
.ok_or_else(|| ApiError::not_implemented("TRANSCODE_DIR not configured"))?;
tm.clear_cache()
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
// ============================================================================
// Helpers
// ============================================================================
#[cfg(feature = "local-files")]
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",
}
}
#[cfg(feature = "local-files")]
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))
}