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:
@@ -44,6 +44,11 @@ pub struct Config {
|
||||
/// Root directory for the local-files provider. Set `LOCAL_FILES_DIR` to enable.
|
||||
pub local_files_dir: Option<PathBuf>,
|
||||
|
||||
/// Directory for FFmpeg HLS transcode cache. Set `TRANSCODE_DIR` to enable transcoding.
|
||||
pub transcode_dir: Option<PathBuf>,
|
||||
/// How long (hours) to keep transcode cache entries before cleanup. Default 24.
|
||||
pub transcode_cleanup_ttl_hours: u32,
|
||||
|
||||
/// Public base URL of this API server (used to build IPTV stream URLs).
|
||||
pub base_url: String,
|
||||
}
|
||||
@@ -120,6 +125,12 @@ impl Config {
|
||||
|
||||
let local_files_dir = env::var("LOCAL_FILES_DIR").ok().map(PathBuf::from);
|
||||
|
||||
let transcode_dir = env::var("TRANSCODE_DIR").ok().map(PathBuf::from);
|
||||
let transcode_cleanup_ttl_hours = env::var("TRANSCODE_CLEANUP_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(24);
|
||||
|
||||
let base_url = env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| format!("http://localhost:{}", port));
|
||||
|
||||
@@ -147,6 +158,8 @@ impl Config {
|
||||
jellyfin_api_key,
|
||||
jellyfin_user_id,
|
||||
local_files_dir,
|
||||
transcode_dir,
|
||||
transcode_cleanup_ttl_hours,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,26 @@ pub struct ScheduleResponse {
|
||||
pub slots: Vec<ScheduledSlotResponse>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transcode DTOs
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TranscodeSettingsResponse {
|
||||
pub cleanup_ttl_hours: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTranscodeSettingsRequest {
|
||||
pub cleanup_ttl_hours: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TranscodeStatsResponse {
|
||||
pub cache_size_bytes: u64,
|
||||
pub item_count: usize,
|
||||
}
|
||||
|
||||
impl From<domain::GeneratedSchedule> for ScheduleResponse {
|
||||
fn from(s: domain::GeneratedSchedule) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -75,6 +75,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Build provider registry — all configured providers are registered simultaneously.
|
||||
#[cfg(feature = "local-files")]
|
||||
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
|
||||
#[cfg(feature = "local-files")]
|
||||
let mut transcode_manager: Option<Arc<infra::TranscodeManager>> = None;
|
||||
#[cfg(feature = "local-files")]
|
||||
let mut sqlite_pool_for_state: Option<sqlx::SqlitePool> = None;
|
||||
|
||||
let mut registry = infra::ProviderRegistry::new();
|
||||
|
||||
@@ -99,12 +103,41 @@ async fn main() -> anyhow::Result<()> {
|
||||
let lf_cfg = infra::LocalFilesConfig {
|
||||
root_dir: dir.clone(),
|
||||
base_url: config.base_url.clone(),
|
||||
transcode_dir: config.transcode_dir.clone(),
|
||||
cleanup_ttl_hours: config.transcode_cleanup_ttl_hours,
|
||||
};
|
||||
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; });
|
||||
registry.register("local", Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||
|
||||
// Build TranscodeManager if TRANSCODE_DIR is set.
|
||||
let tm = config.transcode_dir.as_ref().map(|td| {
|
||||
std::fs::create_dir_all(td).ok();
|
||||
tracing::info!("Transcoding enabled; cache dir: {:?}", td);
|
||||
let tm = infra::TranscodeManager::new(td.clone(), config.transcode_cleanup_ttl_hours);
|
||||
// Load persisted TTL from DB.
|
||||
let tm_clone = Arc::clone(&tm);
|
||||
let pool_clone = sqlite_pool.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(row) = sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1",
|
||||
)
|
||||
.fetch_one(&pool_clone)
|
||||
.await
|
||||
{
|
||||
tm_clone.set_cleanup_ttl(row.0 as u32);
|
||||
}
|
||||
});
|
||||
tm
|
||||
});
|
||||
|
||||
registry.register(
|
||||
"local",
|
||||
Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg, tm.clone())),
|
||||
);
|
||||
transcode_manager = tm;
|
||||
sqlite_pool_for_state = Some(sqlite_pool.clone());
|
||||
} else {
|
||||
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
|
||||
}
|
||||
@@ -137,6 +170,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
#[cfg(feature = "local-files")]
|
||||
{
|
||||
state.local_index = local_index;
|
||||
state.transcode_manager = transcode_manager;
|
||||
state.sqlite_pool = sqlite_pool_for_state;
|
||||
}
|
||||
|
||||
let server_config = ServerConfig {
|
||||
@@ -206,6 +241,7 @@ impl IMediaProvider for NoopMediaProvider {
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,12 @@ pub struct AppState {
|
||||
/// Index for the local-files provider, used by the rescan route.
|
||||
#[cfg(feature = "local-files")]
|
||||
pub local_index: Option<Arc<infra::LocalIndex>>,
|
||||
/// TranscodeManager for FFmpeg HLS transcoding (requires TRANSCODE_DIR).
|
||||
#[cfg(feature = "local-files")]
|
||||
pub transcode_manager: Option<Arc<infra::TranscodeManager>>,
|
||||
/// SQLite pool for transcode settings CRUD.
|
||||
#[cfg(feature = "local-files")]
|
||||
pub sqlite_pool: Option<sqlx::SqlitePool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -110,6 +116,10 @@ impl AppState {
|
||||
config: Arc::new(config),
|
||||
#[cfg(feature = "local-files")]
|
||||
local_index: None,
|
||||
#[cfg(feature = "local-files")]
|
||||
transcode_manager: None,
|
||||
#[cfg(feature = "local-files")]
|
||||
sqlite_pool: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user