feat(transcoding): add FFmpeg HLS transcoding support

- Introduced `TranscodeManager` for managing on-demand transcoding of local video files.
- Added configuration options for transcoding in `Config` and `LocalFilesConfig`.
- Implemented new API routes for managing transcoding settings, stats, and cache.
- Updated `LocalFilesProvider` to support transcoding capabilities.
- Created frontend components for managing transcode settings and displaying stats.
- Added database migration for transcode settings.
- Enhanced existing routes and DTOs to accommodate new transcoding features.
This commit is contained in:
2026-03-15 00:34:23 +01:00
parent ead65e6be2
commit 1102e385f3
23 changed files with 865 additions and 31 deletions

View File

@@ -33,6 +33,7 @@ async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
search: false,
streaming_protocol: StreamingProtocol::DirectFile,
rescan: false,
transcode: false,
});
Json(ConfigResponse {

View File

@@ -1,30 +1,58 @@
//! Local-file streaming and rescan routes
//! Local-file streaming, rescan, and transcode routes.
//!
//! GET /files/stream/:encoded_id — serve a local video file with Range support
//! POST /files/rescan — trigger an index rebuild (auth required)
//! 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, StatusCode},
http::HeaderMap,
response::Response,
routing::{get, post},
routing::get,
};
use crate::{error::ApiError, extractors::CurrentUser, state::AppState};
use crate::{error::ApiError, state::AppState};
#[cfg(feature = "local-files")]
use axum::{
Json,
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));
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
}
/// 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.
// ============================================================================
// Direct streaming
// ============================================================================
async fn stream_file(
State(state): State<AppState>,
Path(encoded_id): Path<String>,
@@ -44,7 +72,6 @@ async fn stream_file(
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()
@@ -72,7 +99,6 @@ async fn stream_file(
.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())
@@ -112,20 +138,208 @@ async fn stream_file(
Err(ApiError::not_implemented("local-files feature not enabled"))
}
/// Trigger a filesystem rescan and return the number of items found.
// ============================================================================
// Rescan
// ============================================================================
#[cfg(feature = "local-files")]
async fn trigger_rescan(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
) -> Result<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 })))
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
.as_ref()
.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
.as_ref()
.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 pool = state
.sqlite_pool
.as_ref()
.ok_or_else(|| ApiError::not_implemented("sqlite not available"))?;
let (ttl,): (i64,) =
sqlx::query_as("SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1")
.fetch_one(pool)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(TranscodeSettingsResponse {
cleanup_ttl_hours: ttl as u32,
}))
}
#[cfg(feature = "local-files")]
async fn update_transcode_settings(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Json(req): Json<UpdateTranscodeSettingsRequest>,
) -> Result<Json<TranscodeSettingsResponse>, ApiError> {
let pool = state
.sqlite_pool
.as_ref()
.ok_or_else(|| ApiError::not_implemented("sqlite not available"))?;
let ttl = req.cleanup_ttl_hours as i64;
sqlx::query("UPDATE transcode_settings SET cleanup_ttl_hours = ? WHERE id = 1")
.bind(ttl)
.execute(pool)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
if let Some(tm) = &state.transcode_manager {
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
.as_ref()
.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
.as_ref()
.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
// ============================================================================
fn content_type_for_ext(ext: &str) -> &'static str {
match ext {
"mp4" | "m4v" => "video/mp4",