use crate::config::{AccessType, Config}; use crate::file_access::{ FileAccess, LocalFileAccess, SmbAccessConfig, SmbFileAccess, SynologyFileAccess, }; use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet}; use crate::synology::SynologyClient; use axum::{ extract::{Path, State}, http::{header, HeaderMap, StatusCode}, response::{Html, IntoResponse, Json}, routing::{get, post}, Router, }; use serde::Serialize; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; use tower_http::trace::TraceLayer; #[derive(Clone)] pub struct AppState { pub config: Config, pub last_generation: Arc>>>, pub source_access: Arc, pub dest_access: Arc, } #[derive(Serialize)] pub struct JobInfo { pub uid: String, pub outfile_name: String, pub state: String, pub created_at: Option, pub updated_at: Option, } impl JobInfo { fn from_nexrender_json(job: &serde_json::Value) -> Option { let uid = job.get("uid")?.as_str()?.to_string(); let state = job.get("state")?.as_str()?.to_string(); let outfile_name = job .get("actions") .and_then(|a| a.get("postrender")) .and_then(|p| p.as_array()) .and_then(|arr| { arr.iter() .find_map(|action| { action .get("module") .and_then(|m| m.as_str()) .filter(|&m| m == "@nexrender/action-copy") .and_then(|_| action.get("output").and_then(|o| o.as_str())) }) .or_else(|| { arr.iter() .find_map(|action| action.get("output").and_then(|o| o.as_str())) }) }) .map(|path| { std::path::Path::new(path) .file_name() .and_then(|n| n.to_str()) .unwrap_or(path) .to_string() }) .unwrap_or_else(|| format!("job_{}", uid)); Some(JobInfo { uid, outfile_name, state, created_at: job .get("createdAt") .and_then(|v| v.as_str()) .map(|s| s.to_string()), updated_at: job .get("updatedAt") .and_then(|v| v.as_str()) .map(|s| s.to_string()), }) } } pub async fn run_web_server(config: Config) -> anyhow::Result<()> { let source_access = create_file_access(&config.source, &config).await?; let dest_access = create_file_access(&config.destination, &config).await?; let web_port = config.web_port; let state = AppState { config, last_generation: Arc::new(Mutex::new(None)), source_access, dest_access, }; let app = Router::new() .route("/", get(index_page)) .route("/favicon.ico", get(favicon)) .route("/static/style.css", get(style_css)) .route("/static/fontawesome/all.min.css", get(fontawesome_css)) .route( "/static/fontawesome/webfonts/fa-solid-900.woff2", get(fa_solid_woff2), ) .route( "/static/fontawesome/webfonts/fa-regular-400.woff2", get(fa_regular_woff2), ) .route( "/static/fontawesome/webfonts/fa-brands-400.woff2", get(fa_brands_woff2), ) .route("/assets/logo.png", get(logo_png)) .route("/api/jobs", get(list_jobs)) .route("/api/generate", post(generate_jobs)) .route("/api/cleanup", post(cleanup_jobs)) .route("/api/status", get(get_status)) .route("/api/jobs/stop-all", post(stop_all_jobs)) .route("/api/videos", get(list_videos)) .route("/api/videos/{filename}", get(stream_video)) .route("/api/videos/{filename}/move", post(move_video)) .route("/api/videos/{filename}/status", get(get_video_status)) .layer(TraceLayer::new_for_http()) .with_state(state); let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?; log::info!("Web server listening on http://{}", addr); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; Ok(()) } async fn create_file_access( access_config: &crate::config::AccessConfig, nas_config: &Config, ) -> anyhow::Result> { match access_config.access_type { AccessType::Local => { let path = access_config.local_path.as_ref().unwrap(); Ok(Arc::new(LocalFileAccess::new(path))) } AccessType::Smb => { let url = access_config.smb_url.as_ref().unwrap().clone(); let user = access_config.smb_user.clone(); let password = access_config.smb_pass.clone(); let domain = access_config.smb_domain.clone(); let smb_config = SmbAccessConfig { url, user, password, domain, }; let access: Arc = Arc::new(SmbFileAccess::new(smb_config)); Ok(access) } AccessType::Synology => { let mut client = SynologyClient::new(&nas_config.nas_fqdn); client .login(&nas_config.nas_user, &nas_config.nas_pass) .await?; let base_path = access_config.synology_path.as_ref().unwrap().clone(); Ok(Arc::new(SynologyFileAccess::new(client, &base_path))) } } } // Статические ресурсы async fn index_page() -> Html<&'static str> { Html(include_str!("static/index.html")) } async fn style_css() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "text/css")], include_str!("static/style.css"), ) } async fn fontawesome_css() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "text/css")], include_str!("static/fontawesome/all.min.css"), ) } async fn logo_png() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "image/png")], include_bytes!("../assets/logo.png").as_slice(), ) } async fn favicon() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "image/x-icon")], include_bytes!("static/favicon.ico").as_slice(), ) } // Управление заданиями Nexrender async fn list_jobs(State(state): State) -> Result>, AppError> { let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url) .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let jobs: Vec = jobs_json .iter() .filter_map(JobInfo::from_nexrender_json) .collect(); Ok(Json(jobs)) } async fn generate_jobs(State(state): State) -> Result { let mut last_gen = state.last_generation.lock().await; if let Some(last) = *last_gen { let elapsed = chrono::Local::now().signed_duration_since(last); if elapsed.num_seconds() < 5 { return Err(AppError( StatusCode::TOO_MANY_REQUESTS, "Generation already in progress or too recent".to_string(), )); } } *last_gen = Some(chrono::Local::now()); drop(last_gen); let config = state.config.clone(); let last_gen_clone = state.last_generation.clone(); tokio::spawn(async move { match process_spreadsheet(&config).await { Ok(submitted) => { log::info!("Generation completed, {} jobs submitted", submitted.len()); } Err(e) => { log::error!("Generation failed: {}", e); } } *last_gen_clone.lock().await = None; }); Ok((StatusCode::ACCEPTED, "Job generation started")) } async fn cleanup_jobs(State(state): State) -> Result { cleanup_finished_jobs(&state.config.nexrender_api_url) .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok((StatusCode::OK, "Cleanup completed")) } async fn get_status(State(state): State) -> Result, AppError> { let last_gen = *state.last_generation.lock().await; let status = serde_json::json!({ "last_generation": last_gen.map(|dt| dt.to_rfc3339()), "nexrender_api": state.config.nexrender_api_url, }); Ok(Json(status)) } async fn stop_all_jobs(State(state): State) -> Result { use reqwest::Client; let client = Client::new(); let api_url = &state.config.nexrender_api_url; let jobs = fetch_all_jobs(api_url) .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut stopped = 0; for job in jobs { let state = job.get("state").and_then(|s| s.as_str()).unwrap_or(""); if state == "queued" || state == "started" || state == "processing" { if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) { let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await; stopped += 1; log::info!("Stopped job: {}", uid); } } } log::info!("Stopped {} active jobs", stopped); Ok((StatusCode::OK, format!("Stopped {} jobs", stopped))) } // Видео async fn list_videos(State(state): State) -> Result>, AppError> { let files = state .source_access .list_files() .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let videos: Vec = files .into_iter() .filter(|name| { let lower = name.to_lowercase(); lower.ends_with(".mp4") || lower.ends_with(".mov") || lower.ends_with(".avi") }) .collect(); Ok(Json(videos)) } async fn stream_video( Path(filename): Path, State(state): State, headers: axum::http::HeaderMap, ) -> Result { log::info!("Stream video requested: {}", filename); let file_data = state .source_access .read_file(&filename) .await .map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?; let file_size = file_data.len() as u64; let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); let (status, content_range, start, end) = if let Some(range_str) = range { let range_str = range_str.trim_start_matches("bytes="); let parts: Vec<&str> = range_str.split('-').collect(); let start: u64 = parts[0].parse().unwrap_or(0); let end: u64 = if parts.len() > 1 && !parts[1].is_empty() { parts[1].parse().unwrap_or(file_size - 1) } else { file_size - 1 }; ( StatusCode::PARTIAL_CONTENT, Some(format!("bytes {}-{}/{}", start, end, file_size)), start, end, ) } else { (StatusCode::OK, None, 0, file_size - 1) }; let mut resp_headers = HeaderMap::new(); resp_headers.insert(header::CONTENT_TYPE, "video/mp4".parse().unwrap()); if let Some(content_range) = content_range { resp_headers.insert(header::CONTENT_RANGE, content_range.parse().unwrap()); } let len = end - start + 1; resp_headers.insert(header::CONTENT_LENGTH, len.to_string().parse().unwrap()); resp_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap()); let body = file_data[start as usize..=end as usize].to_vec(); Ok((status, resp_headers, body)) } async fn move_video( Path(filename): Path, State(state): State, axum::extract::Query(params): axum::extract::Query>, ) -> Result { let remove = params.get("remove").map(|s| s == "true").unwrap_or(false); let data = state .source_access .read_file(&filename) .await .map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?; // Определяем целевую подпапку по имени файла let subfolder = if filename.contains("_Today") { "Today" } else if filename.contains("_Tomorrow") { "Tomorrow" } else { "Data" }; let dest_filename = format!("{}/{}", subfolder, filename); // Проверяем, существует ли уже файл в целевой папке if state.dest_access.read_file(&dest_filename).await.is_ok() { return Ok(( StatusCode::OK, format!("File {} already exists in {} folder.", filename, subfolder), )); } // Запись state .dest_access .write_file(&dest_filename, &data) .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut message = format!( "File {} moved successfully to {} folder.", filename, subfolder ); if remove { state .source_access .delete_file(&filename) .await .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; message.push_str(" Please delete the source file manually if needed."); } Ok((StatusCode::OK, message)) } // Шрифты Font Awesome async fn fa_solid_woff2() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "font/woff2")], include_bytes!("static/fontawesome/webfonts/fa-solid-900.woff2").as_slice(), ) } async fn fa_regular_woff2() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "font/woff2")], include_bytes!("static/fontawesome/webfonts/fa-regular-400.woff2").as_slice(), ) } async fn fa_brands_woff2() -> impl IntoResponse { ( [(header::CONTENT_TYPE, "font/woff2")], include_bytes!("static/fontawesome/webfonts/fa-brands-400.woff2").as_slice(), ) } async fn get_video_status( Path(filename): Path, State(state): State, ) -> Result, AppError> { let subfolder = if filename.contains("_Today") { "Today" } else if filename.contains("_Tomorrow") { "Tomorrow" } else { "Data" }; let dest_filename = format!("{}/{}", subfolder, filename); let exists = state.dest_access.read_file(&dest_filename).await.is_ok(); Ok(Json(serde_json::json!({ "exists_in_dest": exists, "subfolder": subfolder }))) } struct AppError(StatusCode, String); impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { (self.0, self.1).into_response() } }