//! 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, 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 { 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 // ============================================================================ async fn stream_file( State(state): State, Path(encoded_id): Path, headers: HeaderMap, ) -> Result { #[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), ); } 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")) } // ============================================================================ // Rescan // ============================================================================ #[cfg(feature = "local-files")] async fn trigger_rescan( State(state): State, CurrentUser(_user): CurrentUser, ) -> Result, ApiError> { let index = state.local_index.read().await.clone() .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, Path(id): Path, ) -> Result { 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, Path(params): Path, ) -> Result { 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, CurrentUser(_user): CurrentUser, ) -> Result, 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, CurrentUser(_user): CurrentUser, Json(req): Json, ) -> Result, 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, CurrentUser(_user): CurrentUser, ) -> Result, 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, CurrentUser(_user): CurrentUser, ) -> Result { 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 // ============================================================================ 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)) }