344 lines
12 KiB
Rust
344 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,
|
|
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
|
|
// ============================================================================
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
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<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
) -> Result<Json<serde_json::Value>, 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<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
|
|
// ============================================================================
|
|
|
|
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))
|
|
}
|