From 8721353b8dc5d398f27bcf0d045ecc000241287c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 17 Apr 2026 11:58:16 +0300 Subject: [PATCH 1/4] v0.2.0 --- Cargo.lock | 229 +++++++++++++++++++- Cargo.toml | 9 +- src/main.rs | 484 ++---------------------------------------- src/processor.rs | 434 +++++++++++++++++++++++++++++++++++++ src/static/index.html | 214 +++++++++++++++++++ src/web.rs | 186 ++++++++++++++++ 6 files changed, 1093 insertions(+), 463 deletions(-) create mode 100644 src/processor.rs create mode 100644 src/static/index.html create mode 100644 src/web.rs diff --git a/Cargo.lock b/Cargo.lock index 202edea..53222ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,12 +10,15 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ae_anons" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", + "askama", + "axum", "bytes", "calamine", "chrono", + "clap", "dotenv", "env_logger", "futures", @@ -27,6 +30,8 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tower", + "tower-http", "urlencoding", ] @@ -113,6 +118,58 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "askama" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -125,12 +182,73 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -199,6 +317,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "codepage" version = "0.1.2" @@ -519,6 +677,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -552,12 +716,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -572,6 +748,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -869,6 +1046,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1199,6 +1382,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -1338,6 +1527,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1400,6 +1600,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1575,6 +1781,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1585,14 +1792,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1613,6 +1830,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -1940,6 +2158,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 5c7857d..62118ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ae_anons" -version = "0.1.1" +version = "0.2.0" edition = "2021" license = "MIT" license-file = "LICENSE" @@ -28,6 +28,13 @@ log = "0.4" env_logger = "0.11" bytes = "1.9" +# Web server (updated to latest stable versions) +axum = "0.8.9" +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "trace"] } +askama = "0.15" +clap = { version = "4.5", features = ["derive"] } + [dev-dependencies] tokio = { version = "1.0", features = ["full", "rt-multi-thread"] } diff --git a/src/main.rs b/src/main.rs index 3a1fbd5..f78a737 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,480 +1,42 @@ mod config; mod nexrender; mod synology; +mod processor; +mod web; -use anyhow::{anyhow, Result}; -use calamine::{Data, Reader, Xlsx}; -use chrono::{Duration, NaiveDate}; +use anyhow::Result; +use clap::Parser; use config::Config; -use log::{debug, error, info}; -use nexrender::{JobData, LogoRegistry}; -use reqwest::Client; -use serde_json::Value; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::time::Duration as StdDuration; -use synology::SynologyClient; -use tokio::time::sleep; +use log::info; -// Структуры для in-memory данных -#[derive(Debug, Clone)] -pub struct SheetData { - pub name: String, - pub headers: Vec, - pub rows: Vec>, -} +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Run web server instead of one-time processing + #[arg(short, long)] + web: bool, -#[derive(Debug, Default)] -pub struct ExcelWorkbook { - pub sheets: Vec, + /// Run one-time processing (default if no flags) + #[arg(short, long)] + once: bool, } #[tokio::main] async fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - info!("Starting AE Anons processor v0.1.1 (in-memory mode)"); + let cli = Cli::parse(); let config = Config::from_env()?; - debug!("Configuration loaded"); - let mut client = SynologyClient::new(&config.nas_fqdn); - client.login(&config.nas_user, &config.nas_pass).await?; - info!("Successfully authenticated with Synology NAS"); - - let info = client.get_info().await?; - info!("Connected to NAS: {}", info.hostname); - - // Скачиваем и парсим Excel в памяти - let workbook = download_and_parse_excel_in_memory(&mut client, &config).await?; - display_workbook_structure(&workbook); - - process_nexrender_jobs(&workbook, &config).await?; - - client.logout().await?; - info!("Session terminated successfully"); - - Ok(()) -} - -async fn download_and_parse_excel_in_memory( - client: &mut SynologyClient, - config: &Config, -) -> Result { - let file_name_full = Path::new(&config.nas_file) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - let search_name = file_name_full.replace(".osheet", ""); - let expected_path = Path::new(&config.nas_file) - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(""); - - let api_format_path = expected_path - .replace("Team Folder", "team-folders") - .replace(' ', ""); - - let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full); - info!("Searching for file: {}", full_expected_path_api); - - let search_result = client.search_file_by_name(&search_name).await?; - let items = search_result["data"]["items"] - .as_array() - .ok_or_else(|| anyhow!("No items in search result"))?; - - if items.is_empty() { - return Err(anyhow!("File '{}' not found", file_name_full)); - } - - let exact_match: Vec<&Value> = items - .iter() - .filter(|item| { - item.get("display_path") - .and_then(|p| p.as_str()) - .unwrap_or("") - == full_expected_path_api - }) - .collect(); - - if exact_match.is_empty() { - return Err(anyhow!("File not found at: {}", full_expected_path_api)); - } - - let file = exact_match[0]; - let file_id = file["file_id"] - .as_str() - .ok_or_else(|| anyhow!("Missing file_id"))?; - let actual_file_name = file["name"] - .as_str() - .ok_or_else(|| anyhow!("Missing name"))?; - - info!("Found file: {}", actual_file_name); - info!("Exporting file from Synology Office to Excel format (in-memory)..."); - - // Получаем бинарные данные Excel напрямую в память - let excel_data = client.export_by_file_id(file_id, actual_file_name).await?; - info!("Exported {} bytes to memory", excel_data.len()); - - info!("Parsing Excel workbook from memory..."); - parse_excel_from_bytes(&excel_data) -} - -fn parse_excel_from_bytes(data: &[u8]) -> Result { - let cursor = Cursor::new(data); - let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor) - .map_err(|e| anyhow!("Failed to open workbook from memory: {}", e))?; - - let sheet_names = workbook.sheet_names().to_vec(); - let mut excel_workbook = ExcelWorkbook::default(); - - for sheet_name in sheet_names { - debug!("Processing sheet: '{}'", sheet_name); - - let range = workbook - .worksheet_range(&sheet_name) - .map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?; - - let sheet_data = parse_sheet_dynamic_optimized(&sheet_name, &range)?; - excel_workbook.sheets.push(sheet_data); - } - - Ok(excel_workbook) -} - -fn parse_sheet_dynamic_optimized( - sheet_name: &str, - range: &calamine::Range, -) -> Result { - // Предварительно выделяем память для избежания реаллокаций - let (row_count, col_count) = range.get_size(); - let mut data_matrix: Vec> = Vec::with_capacity(row_count); - - // Строим матрицу данных (без enumerate) - for row in range.rows() { - let mut row_data = Vec::with_capacity(col_count); - for cell in row { - row_data.push(cell_to_string_optimized(cell)); - } - data_matrix.push(row_data); - } - if data_matrix.is_empty() { - return Ok(SheetData { - name: sheet_name.to_string(), - headers: Vec::new(), - rows: Vec::new(), - }); - } - - // Извлекаем заголовки с дедупликацией - let headers: Vec = data_matrix[0] - .iter() - .enumerate() - .map(|(idx, header)| { - let h = header.trim().to_string(); - if h.is_empty() { - format!("Column_{}", idx + 1) - } else { - h - } - }) - .collect(); - - // Парсим строки данных - let rows_data: Vec> = data_matrix - .iter() - .skip(1) - .filter_map(|row_values| { - let mut row_map = HashMap::with_capacity(headers.len()); - let mut has_data = false; - - for (col_idx, value) in row_values.iter().enumerate() { - if col_idx < headers.len() && !value.is_empty() { - row_map.insert(headers[col_idx].clone(), value.clone()); - has_data = true; - } - } - - if has_data { - Some(row_map) - } else { - None - } - }) - .take(10000) // Ограничение для безопасности - .collect(); - - Ok(SheetData { - name: sheet_name.to_string(), - headers, - rows: rows_data, - }) -} - -#[inline] -fn cell_to_string_optimized(cell: &Data) -> String { - match cell { - Data::Empty => String::new(), - Data::String(s) => s.clone(), - Data::Float(f) => { - if is_excel_date(*f) { - excel_date_to_string(*f) - } else if f.fract() == 0.0 { - // Используем itoa для целых чисел (опционально) - format!("{:.0}", f) - } else { - // Используем ryu для float (опционально) - f.to_string() - } - } - Data::Int(i) => { - let f = *i as f64; - if is_excel_date(f) { - excel_date_to_string(f) - } else { - i.to_string() - } - } - Data::Bool(b) => { - if *b { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - Data::DateTime(dt) => { - let serial = dt.as_f64(); - if is_excel_date(serial) { - excel_date_to_string(serial) - } else { - dt.to_string() - } - } - Data::DateTimeIso(s) => s.clone(), - Data::DurationIso(s) => s.clone(), - Data::Error(e) => format!("{:?}", e), - } -} - -#[inline] -fn is_excel_date(value: f64) -> bool { - (1.0..100000.0).contains(&value) -} - -fn excel_date_to_string(serial: f64) -> String { - let days = serial as i64; - let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap(); - if let Some(date) = base.checked_add_signed(Duration::days(days)) { - // Используем метод format напрямую - он публичный - date.format("%d.%m.%Y").to_string() + if cli.web { + info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION")); + web::run_web_server(config).await?; } else { - serial.to_string() - } -} - -fn display_workbook_structure(workbook: &ExcelWorkbook) { - info!("Workbook contains {} sheets", workbook.sheets.len()); - - for (idx, sheet) in workbook.sheets.iter().enumerate() { - debug!( - "Sheet #{}: '{}' | Headers: {} | Rows: {}", - idx + 1, - sheet.name, - sheet.headers.len(), - sheet.rows.len() - ); - } -} - -async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Result<()> { - info!("Preparing Nexrender jobs..."); - - let start_sheet = workbook - .get_sheet("Start") - .ok_or_else(|| anyhow!("Sheet 'Start' not found"))?; - let sport_sheet = workbook - .get_sheet("SPORT") - .ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; - let teams_sheet = workbook - .get_sheet("TEAMS") - .ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?; - let channel_sheet = workbook - .get_sheet("CHANELL") - .ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?; - - // Используем предварительное выделение памяти - let packs: HashMap = sport_sheet - .rows - .iter() - .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) - .collect(); - info!("Loaded {} sport packs", packs.len()); - - let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len()); - for row in &teams_sheet.rows { - if let (Some(team), Some(sport), Some(link)) = - (row.get("TEAM"), row.get("SPORT"), row.get("LINK")) - { - logos.insert(team.clone(), sport.clone(), link.clone()); - } - } - info!("Loaded {} team logos", teams_sheet.rows.len()); - - let channels: HashMap = channel_sheet - .rows - .iter() - .filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone()))) - .collect(); - info!("Loaded {} channel logos", channels.len()); - - // Предварительно выделяем память для jobs - let mut jobs: Vec = Vec::with_capacity(start_sheet.rows.len() * 3); - - for (idx, row) in start_sheet.rows.iter().enumerate() { - if let Some(state) = row.get("STATE") { - if state == "FALSE" { - if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { - jobs.extend(job.create_variants()); - } - } - } - } - - info!("Generated {} total jobs (including variants)", jobs.len()); - - if jobs.is_empty() { - info!("No jobs with STATE='FALSE' found"); - return Ok(()); - } - - cleanup_finished_jobs(&config.nexrender_api_url).await?; - - let http_client = Client::new(); - let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); - - // Отправляем jobs параллельно для ускорения - let mut tasks = Vec::with_capacity(jobs.len()); - for job in jobs { - let client = http_client.clone(); - let api_url = config.nexrender_api_url.clone(); - let config_clone = config.clone(); // ✅ Клонируем весь конфиг - - tasks.push(tokio::spawn(async move { - let nexrender_job = job.to_nexrender_job(&config_clone); // ✅ Передаём Config - info!("Submitting job: {}", job.outfile_name); - - let response = client.post(&api_url).json(&nexrender_job).send().await?; - - if response.status().is_success() { - let result: Value = response.json().await?; - if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) { - Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name))) - } else { - Ok(None) - } - } else { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - error!("Failed to submit job ({}): {}", status, text); - Ok(None) - } - })); - } - - for task in tasks { - match task.await { - Ok(Ok(Some((uid, outfile_name)))) => { - info!("Job submitted: {}", outfile_name); - submitted_jobs.push((uid, outfile_name)); - } - Ok(Ok(None)) => { - debug!("Job submission returned no UID"); - } - Ok(Err(e)) => { - error!("Job submission error: {}", e); - } - Err(e) => { - error!("Task join error: {}", e); - } - } - } - - info!( - "Successfully submitted {} jobs to Nexrender", - submitted_jobs.len() - ); - - if !submitted_jobs.is_empty() { - info!("Monitoring job completion..."); - monitor_jobs(&http_client, &config.nexrender_api_url, submitted_jobs).await?; + // По умолчанию или с флагом --once выполняем однократную обработку + info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION")); + let submitted = processor::process_spreadsheet(&config).await?; + info!("Submitted {} jobs. Exiting.", submitted.len()); } Ok(()) -} - -// ... остальные функции без изменений ... -impl ExcelWorkbook { - pub fn get_sheet(&self, name: &str) -> Option<&SheetData> { - self.sheets.iter().find(|s| s.name == name) - } -} - -async fn cleanup_finished_jobs(api_url: &str) -> Result<()> { - let client = Client::new(); - let response = client.get(api_url).send().await?; - - if response.status().is_success() { - let jobs: Vec = response.json().await?; - for job in jobs { - if let (Some(uid), Some(state)) = ( - job.get("uid").and_then(|u| u.as_str()), - job.get("state").and_then(|s| s.as_str()), - ) { - if state == "finished" || state == "error" { - let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await; - info!("Cleaned up completed job: {}", uid); - } - } - } - } - Ok(()) -} - -async fn monitor_jobs( - client: &Client, - api_url: &str, - mut pending: Vec<(String, String)>, -) -> Result<()> { - while !pending.is_empty() { - sleep(StdDuration::from_secs(25)).await; - - let mut remaining = Vec::with_capacity(pending.len()); - for (uid, outname) in pending.drain(..) { - let response = client.get(&format!("{}/{}", api_url, uid)).send().await?; - - if response.status().is_success() { - let job: Value = response.json().await?; - let state = job - .get("state") - .and_then(|s| s.as_str()) - .unwrap_or("unknown"); - - match state { - "finished" => { - info!("Job completed: {}", outname) - } - "error" => error!("Job failed: {}", outname), - _ => remaining.push((uid, outname)), - } - } else { - remaining.push((uid, outname)); - } - } - pending = remaining; - if !pending.is_empty() { - info!("Waiting for {} jobs to complete...", pending.len()); - } - } - info!("All jobs completed successfully"); - Ok(()) -} +} \ No newline at end of file diff --git a/src/processor.rs b/src/processor.rs new file mode 100644 index 0000000..a3115d2 --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,434 @@ +use crate::config::Config; +use crate::nexrender::{JobData, LogoRegistry}; +use crate::synology::SynologyClient; +use anyhow::{anyhow, Result}; +use calamine::{Data, Reader, Xlsx}; +use chrono::{Duration, NaiveDate}; +use log::{debug, error, info}; +use reqwest::Client; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::Path; + +// Добавить после импортов в processor.rs + +#[derive(Debug, Clone)] +pub struct SheetData { + pub name: String, + pub headers: Vec, + pub rows: Vec>, +} + +#[derive(Debug, Default)] +pub struct ExcelWorkbook { + pub sheets: Vec, +} + +impl ExcelWorkbook { + pub fn get_sheet(&self, name: &str) -> Option<&SheetData> { + self.sheets.iter().find(|s| s.name == name) + } +} + +/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender +pub async fn process_spreadsheet(config: &Config) -> Result> { + let mut client = SynologyClient::new(&config.nas_fqdn); + client.login(&config.nas_user, &config.nas_pass).await?; + info!("Successfully authenticated with Synology NAS"); + + let info = client.get_info().await?; + info!("Connected to NAS: {}", info.hostname); + + // Скачиваем и парсим Excel в памяти + let workbook = download_and_parse_excel_in_memory(&mut client, config).await?; + display_workbook_structure(&workbook); + + let submitted = generate_and_submit_jobs(&workbook, config).await?; + + client.logout().await?; + info!("Session terminated successfully"); + + Ok(submitted) +} + +async fn download_and_parse_excel_in_memory( + client: &mut SynologyClient, + config: &Config, +) -> Result { + let file_name_full = Path::new(&config.nas_file) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + let search_name = file_name_full.replace(".osheet", ""); + let expected_path = Path::new(&config.nas_file) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""); + + let api_format_path = expected_path + .replace("Team Folder", "team-folders") + .replace(' ', ""); + + let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full); + info!("Searching for file: {}", full_expected_path_api); + + let search_result = client.search_file_by_name(&search_name).await?; + let items = search_result["data"]["items"] + .as_array() + .ok_or_else(|| anyhow!("No items in search result"))?; + + if items.is_empty() { + return Err(anyhow!("File '{}' not found", file_name_full)); + } + + let exact_match: Vec<&Value> = items + .iter() + .filter(|item| { + item.get("display_path") + .and_then(|p| p.as_str()) + .unwrap_or("") + == full_expected_path_api + }) + .collect(); + + if exact_match.is_empty() { + return Err(anyhow!("File not found at: {}", full_expected_path_api)); + } + + let file = exact_match[0]; + let file_id = file["file_id"] + .as_str() + .ok_or_else(|| anyhow!("Missing file_id"))?; + let actual_file_name = file["name"] + .as_str() + .ok_or_else(|| anyhow!("Missing name"))?; + + info!("Found file: {}", actual_file_name); + info!("Exporting file from Synology Office to Excel format (in-memory)..."); + + // Получаем бинарные данные Excel напрямую в память + let excel_data = client.export_by_file_id(file_id, actual_file_name).await?; + info!("Exported {} bytes to memory", excel_data.len()); + + info!("Parsing Excel workbook from memory..."); + parse_excel_from_bytes(&excel_data) +} + +fn parse_excel_from_bytes(data: &[u8]) -> Result { + let cursor = Cursor::new(data); + let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor) + .map_err(|e| anyhow!("Failed to open workbook from memory: {}", e))?; + + let sheet_names = workbook.sheet_names().to_vec(); + let mut excel_workbook = ExcelWorkbook::default(); + + for sheet_name in sheet_names { + debug!("Processing sheet: '{}'", sheet_name); + + let range = workbook + .worksheet_range(&sheet_name) + .map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?; + + let sheet_data = parse_sheet_dynamic_optimized(&sheet_name, &range)?; + excel_workbook.sheets.push(sheet_data); + } + + Ok(excel_workbook) +} + +fn parse_sheet_dynamic_optimized( + sheet_name: &str, + range: &calamine::Range, +) -> Result { + let (row_count, col_count) = range.get_size(); + let mut data_matrix: Vec> = Vec::with_capacity(row_count); + + for row in range.rows() { + let mut row_data = Vec::with_capacity(col_count); + for cell in row { + row_data.push(cell_to_string_optimized(cell)); + } + data_matrix.push(row_data); + } + + if data_matrix.is_empty() { + return Ok(SheetData { + name: sheet_name.to_string(), + headers: Vec::new(), + rows: Vec::new(), + }); + } + + // Извлекаем заголовки с дедупликацией + let headers: Vec = data_matrix[0] + .iter() + .enumerate() + .map(|(idx, header)| { + let h = header.trim().to_string(); + if h.is_empty() { + format!("Column_{}", idx + 1) + } else { + h + } + }) + .collect(); + + // Парсим строки данных + let rows_data: Vec> = data_matrix + .iter() + .skip(1) + .filter_map(|row_values| { + let mut row_map = HashMap::with_capacity(headers.len()); + let mut has_data = false; + + for (col_idx, value) in row_values.iter().enumerate() { + if col_idx < headers.len() && !value.is_empty() { + row_map.insert(headers[col_idx].clone(), value.clone()); + has_data = true; + } + } + + if has_data { + Some(row_map) + } else { + None + } + }) + .take(10000) // Ограничение для безопасности + .collect(); + + Ok(SheetData { + name: sheet_name.to_string(), + headers, + rows: rows_data, + }) +} + +#[inline] +fn cell_to_string_optimized(cell: &Data) -> String { + match cell { + Data::Empty => String::new(), + Data::String(s) => s.clone(), + Data::Float(f) => { + if is_excel_date(*f) { + excel_date_to_string(*f) + } else if f.fract() == 0.0 { + // Используем itoa для целых чисел (опционально) + format!("{:.0}", f) + } else { + // Используем ryu для float (опционально) + f.to_string() + } + } + Data::Int(i) => { + let f = *i as f64; + if is_excel_date(f) { + excel_date_to_string(f) + } else { + i.to_string() + } + } + Data::Bool(b) => { + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } + Data::DateTime(dt) => { + let serial = dt.as_f64(); + if is_excel_date(serial) { + excel_date_to_string(serial) + } else { + dt.to_string() + } + } + Data::DateTimeIso(s) => s.clone(), + Data::DurationIso(s) => s.clone(), + Data::Error(e) => format!("{:?}", e), + } +} + +#[inline] +fn is_excel_date(value: f64) -> bool { + (1.0..100000.0).contains(&value) +} + +fn excel_date_to_string(serial: f64) -> String { + let days = serial as i64; + let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap(); + if let Some(date) = base.checked_add_signed(Duration::days(days)) { + // Используем метод format напрямую - он публичный + date.format("%d.%m.%Y").to_string() + } else { + serial.to_string() + } +} + +fn display_workbook_structure(workbook: &ExcelWorkbook) { + info!("Workbook contains {} sheets", workbook.sheets.len()); + + for (idx, sheet) in workbook.sheets.iter().enumerate() { + debug!( + "Sheet #{}: '{}' | Headers: {} | Rows: {}", + idx + 1, + sheet.name, + sheet.headers.len(), + sheet.rows.len() + ); + } +} + +async fn generate_and_submit_jobs( + workbook: &ExcelWorkbook, + config: &Config, +) -> Result> { + info!("Preparing Nexrender jobs..."); + + let start_sheet = workbook + .get_sheet("Start") + .ok_or_else(|| anyhow!("Sheet 'Start' not found"))?; + let sport_sheet = workbook + .get_sheet("SPORT") + .ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; + let teams_sheet = workbook + .get_sheet("TEAMS") + .ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?; + let channel_sheet = workbook + .get_sheet("CHANELL") + .ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?; + + let packs: HashMap = sport_sheet + .rows + .iter() + .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) + .collect(); + info!("Loaded {} sport packs", packs.len()); + + let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len()); + for row in &teams_sheet.rows { + if let (Some(team), Some(sport), Some(link)) = + (row.get("TEAM"), row.get("SPORT"), row.get("LINK")) + { + logos.insert(team.clone(), sport.clone(), link.clone()); + } + } + info!("Loaded {} team logos", teams_sheet.rows.len()); + + let channels: HashMap = channel_sheet + .rows + .iter() + .filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone()))) + .collect(); + info!("Loaded {} channel logos", channels.len()); + + let mut jobs: Vec = Vec::with_capacity(start_sheet.rows.len() * 3); + + for (idx, row) in start_sheet.rows.iter().enumerate() { + if let Some(state) = row.get("STATE") { + if state == "FALSE" { + if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { + jobs.extend(job.create_variants()); + } + } + } + } + + info!("Generated {} total jobs (including variants)", jobs.len()); + + if jobs.is_empty() { + info!("No jobs with STATE='FALSE' found"); + return Ok(Vec::new()); + } + + cleanup_finished_jobs(&config.nexrender_api_url).await?; + + let http_client = Client::new(); + let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); + + let mut tasks = Vec::with_capacity(jobs.len()); + for job in jobs { + let client = http_client.clone(); + let api_url = config.nexrender_api_url.clone(); + let config_clone = config.clone(); + + tasks.push(tokio::spawn(async move { + let nexrender_job = job.to_nexrender_job(&config_clone); + info!("Submitting job: {}", job.outfile_name); + + let response = client.post(&api_url).json(&nexrender_job).send().await?; + + if response.status().is_success() { + let result: Value = response.json().await?; + if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) { + Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name))) + } else { + Ok(None) + } + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + error!("Failed to submit job ({}): {}", status, text); + Ok(None) + } + })); + } + + for task in tasks { + match task.await { + Ok(Ok(Some((uid, outfile_name)))) => { + info!("Job submitted: {}", outfile_name); + submitted_jobs.push((uid, outfile_name)); + } + Ok(Ok(None)) => { + debug!("Job submission returned no UID"); + } + Ok(Err(e)) => { + error!("Job submission error: {}", e); + } + Err(e) => { + error!("Task join error: {}", e); + } + } + } + + info!( + "Successfully submitted {} jobs to Nexrender", + submitted_jobs.len() + ); + + Ok(submitted_jobs) +} + +pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> { + let client = Client::new(); + let response = client.get(api_url).send().await?; + + if response.status().is_success() { + let jobs: Vec = response.json().await?; + for job in jobs { + if let (Some(uid), Some(state)) = ( + job.get("uid").and_then(|u| u.as_str()), + job.get("state").and_then(|s| s.as_str()), + ) { + if state == "finished" || state == "error" { + let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await; + info!("Cleaned up completed job: {}", uid); + } + } + } + } + Ok(()) +} + +pub async fn fetch_all_jobs(api_url: &str) -> Result> { + let client = Client::new(); + let response = client.get(api_url).send().await?; + if response.status().is_success() { + let jobs: Vec = response.json().await?; + Ok(jobs) + } else { + Ok(Vec::new()) + } +} diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..bacd5c9 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,214 @@ + + + + + AE Anons Control Panel + + + +
+

🎬 AE Anons - Nexrender Job Manager

+ +
+ + + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + +
Output FileStateCreatedUpdatedUID
Loading...
+
+ + + + \ No newline at end of file diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..9d93e78 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,186 @@ +use crate::config::Config; +use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet}; +use axum::{ + extract::State, + http::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>>>, +} + +#[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(); + + // Извлекаем имя выходного файла из postrender actions + let outfile_name = job + .get("actions") + .and_then(|a| a.get("postrender")) + .and_then(|p| p.as_array()) + .and_then(|arr| { + // Ищем действие copy (в нём финальный путь) + arr.iter() + .find_map(|action| { + // Проверяем, что это действие copy + action + .get("module") + .and_then(|m| m.as_str()) + .filter(|&m| m == "@nexrender/action-copy") + .and_then(|_| { + // Извлекаем output из copy + action.get("output").and_then(|o| o.as_str()) + }) + }) + // Если copy не найдено, пробуем encode + .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(|| { + // Если не удалось извлечь, используем UID + 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 state = AppState { + config, + last_generation: Arc::new(Mutex::new(None)), + }; + + let app = Router::new() + .route("/", get(index_page)) + .route("/api/jobs", get(list_jobs)) + .route("/api/generate", post(generate_jobs)) + .route("/api/cleanup", post(cleanup_jobs)) + .route("/api/status", get(get_status)) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr: SocketAddr = "0.0.0.0:3000".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 index_page() -> Html<&'static str> { + Html(include_str!("static/index.html")) +} + +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)) +} + +// Error handling +struct AppError(StatusCode, String); + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + (self.0, self.1).into_response() + } +} From bb371e1b712e229d20a5c57c2b01424c2c126373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 17 Apr 2026 12:54:43 +0300 Subject: [PATCH 2/4] v0.2.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 20 +- src/main.rs | 14 +- src/nexrender.rs | 1 - src/processor.rs | 3 - src/static/index.html | 549 ++++++++++++++++++++++++++++++----------- src/static/style.css | 555 ++++++++++++++++++++++++++++++++++++++++++ src/web.rs | 32 +++ 9 files changed, 1024 insertions(+), 154 deletions(-) create mode 100644 src/static/style.css diff --git a/Cargo.lock b/Cargo.lock index 53222ca..2b23e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ae_anons" -version = "0.2.0" +version = "0.2.2" dependencies = [ "anyhow", "askama", diff --git a/Cargo.toml b/Cargo.toml index 62118ab..a802007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ae_anons" -version = "0.2.0" +version = "0.2.2" edition = "2021" license = "MIT" license-file = "LICENSE" diff --git a/README.md b/README.md index 0f8f464..05f9583 100644 --- a/README.md +++ b/README.md @@ -342,15 +342,21 @@ RUST_LOG=debug ./target/release/ae_anons ae_anons/ ├── Cargo.toml ├── LICENSE -├── assets/ -│ └── logo.png ├── README.md ├── .env.example -└── src/ - ├── main.rs # Точка входа и оркестрация приложения - ├── config.rs # Управление конфигурацией - ├── nexrender.rs # Генерация заданий Nexrender и структура данных - └── synology.rs # Клиент API Synology +├── src/ +│ ├── main.rs +│ ├── config.rs +│ ├── nexrender.rs +│ ├── synology.rs +│ ├── processor.rs +│ └── web.rs +├── static/ +│ ├── index.html +│ └── style.css +└── assets/ + └── logo.png + ``` ## Зависимости diff --git a/src/main.rs b/src/main.rs index f78a737..0a553ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ mod config; mod nexrender; -mod synology; mod processor; -mod web; +mod synology; +mod web; use anyhow::Result; use clap::Parser; @@ -29,11 +29,17 @@ async fn main() -> Result<()> { let config = Config::from_env()?; if cli.web { - info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION")); + info!( + "Starting AE Anons web server v{}", + env!("CARGO_PKG_VERSION") + ); web::run_web_server(config).await?; } else { // По умолчанию или с флагом --once выполняем однократную обработку - info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION")); + info!( + "Starting AE Anons processor v{} (one-time mode)", + env!("CARGO_PKG_VERSION") + ); let submitted = processor::process_spreadsheet(&config).await?; info!("Submitted {} jobs. Exiting.", submitted.len()); } diff --git a/src/nexrender.rs b/src/nexrender.rs index c9ac6d8..c649fba 100644 --- a/src/nexrender.rs +++ b/src/nexrender.rs @@ -302,7 +302,6 @@ impl JobData { variants } - // ЕДИНСТВЕННАЯ реализация метода pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob { let template = if self.team_b.is_empty() { Template::single( diff --git a/src/processor.rs b/src/processor.rs index a3115d2..90ad180 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -11,8 +11,6 @@ use std::collections::HashMap; use std::io::Cursor; use std::path::Path; -// Добавить после импортов в processor.rs - #[derive(Debug, Clone)] pub struct SheetData { pub name: String, @@ -174,7 +172,6 @@ fn parse_sheet_dynamic_optimized( }) .collect(); - // Парсим строки данных let rows_data: Vec> = data_matrix .iter() .skip(1) diff --git a/src/static/index.html b/src/static/index.html index bacd5c9..3e8a5c4 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -1,214 +1,489 @@ - + + - AE Anons Control Panel - + + AE Anons - Nexrender Job Manager + + +
-

🎬 AE Anons - Nexrender Job Manager

- -
- - - - + +
+
+
+ + +
+

AE Anons - Nexrender Job Manager

+
+
+ +
+ +
+ + + + +
+ + Ready +
+
+ + +
+ +
- +
+ + +
+
+ + Auto-refresh: 60s + 60s +
-
- - - - - - - - - - - - - - -
Output FileStateCreatedUpdatedUID
Loading...
+ +
+ + + + + + + + + + + + + + + +
+ Output File + + State + + Created + + Updated + + UID +
+
+ +

Loading jobs...

+
+
+
+ \ No newline at end of file diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..3b3e2e4 --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,555 @@ +/* ======================================== + AE Anons - Nexrender Job Manager Styles + ======================================== */ + +/* CSS Variables - Light Theme (default) */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #fafafa; + --bg-tertiary: #f3f0f7; + --text-primary: #1a1a1a; + --text-secondary: #6b4f7c; + --border-color: #e0d4e8; + --accent-primary: #7c3aed; + --accent-secondary: #c2410c; + --accent-success: #10b981; + --accent-warning: #ea580c; + --accent-danger: #ef4444; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --header-bg: #7c3aed; + --header-text: #ffffff; +} + +/* Dark Theme */ +[data-theme="dark"] { + --bg-primary: #1a1025; + --bg-secondary: #251a30; + --bg-tertiary: #352545; + --text-primary: #f0e6ff; + --text-secondary: #c4a6d9; + --border-color: #4a3560; + --accent-primary: #a78bfa; + --accent-secondary: #f59e0b; + --accent-success: #34d399; + --accent-warning: #fbbf24; + --accent-danger: #f87171; + --header-bg: #a78bfa; + --header-text: #1a1025; +} + +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 100%; + margin: 0 auto; + padding: 24px; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.logo-container { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--accent-primary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: var(--shadow-md); +} + +.logo { + width: 100%; + height: 100%; + object-fit: cover; +} + +.logo-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 24px; + font-weight: bold; +} + +.header h1 { + font-size: 28px; + font-weight: 600; + color: var(--accent-primary); + letter-spacing: -0.5px; +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.theme-toggle { + background: var(--bg-secondary); + border: 2px solid var(--accent-primary); + border-radius: 24px; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 14px; + box-shadow: var(--shadow-sm); +} + +.theme-toggle:hover { + box-shadow: var(--shadow-md); +} + +/* Action Bar */ +.action-bar { + background: var(--bg-secondary); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + border: 1px solid var(--border-color); +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + opacity: 0; + transition: opacity 0.2s; +} + +.btn:hover::before { + opacity: 1; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.btn i { + font-size: 14px; +} + +/* Generate - Purple */ +.btn-primary { + background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%); + color: white; + border: none; + box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3); +} + +[data-theme="dark"] .btn-primary { + background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%); + color: #1a1025; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4); +} + +.btn-primary:hover { + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); +} + +/* Stop - Orange */ +.btn-warning { + background: linear-gradient(135deg, #ea580c 0%, #f97316 100%); + color: white; + border: none; + box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3); +} + +[data-theme="dark"] .btn-warning { + background: linear-gradient(135deg, #f97316 0%, #fb923c 100%); + color: #1a1025; + box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4); +} + +.btn-warning:hover { + box-shadow: 0 4px 12px rgba(234, 88, 12, 0.4); +} + +/* Cleanup - Plum */ +.btn-danger { + background: linear-gradient(135deg, #9d174d 0%, #be185d 100%); + color: white; + border: none; + box-shadow: 0 2px 8px rgba(157, 23, 77, 0.3); +} + +[data-theme="dark"] .btn-danger { + background: linear-gradient(135deg, #be185d 0%, #db2777 100%); + color: #f0e6ff; + box-shadow: 0 2px 8px rgba(190, 24, 93, 0.4); +} + +.btn-danger:hover { + box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4); +} + +/* Refresh - Outline */ +.btn-outline { + background: transparent; + border: 2px solid #7c3aed; + color: #7c3aed; + box-shadow: none; +} + +[data-theme="dark"] .btn-outline { + border-color: #a78bfa; + color: #a78bfa; +} + +.btn-outline:hover { + background: #7c3aed; + color: white; + border-color: #7c3aed; +} + +[data-theme="dark"] .btn-outline:hover { + background: #a78bfa; + color: #1a1025; + border-color: #a78bfa; +} + +/* Status Message */ +.status-message { + margin-left: auto; + padding: 8px 16px; + background: var(--bg-tertiary); + border-radius: 20px; + font-size: 14px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +/* Stats Cards */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-secondary); + border-radius: 12px; + padding: 20px; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +.stat-card h3 { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; + font-weight: 500; +} + +.stat-card .value { + font-size: 32px; + font-weight: 700; + color: var(--accent-primary); +} + +/* Filter Bar */ +.filter-bar { + margin-bottom: 20px; + display: flex; + gap: 12px; + align-items: center; +} + +.search-wrapper { + flex: 1; + position: relative; +} + +.search-wrapper i { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); +} + +.search-input { + width: 100%; + padding: 12px 16px 12px 44px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 24px; + color: var(--text-primary); + font-size: 14px; +} + +.search-input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); +} + +.auto-refresh-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-tertiary); + border-radius: 20px; + font-size: 13px; + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +/* Table */ +.table-container { + background: var(--bg-secondary); + border-radius: 12px; + box-shadow: var(--shadow-lg); + overflow-x: auto; + border: 1px solid var(--border-color); +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: auto; + min-width: 800px; +} + +th { + text-align: left; + padding: 16px; + background: var(--header-bg); + color: var(--header-text); + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + cursor: pointer; + user-select: none; + position: relative; +} + +th:hover { + opacity: 0.9; +} + +th i { + margin-left: 8px; + font-size: 12px; + opacity: 0.7; +} + +th.sorted-asc i, +th.sorted-desc i { + opacity: 1; +} + +td { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + vertical-align: middle; +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover { + background: var(--bg-tertiary); +} + +/* Job Filename */ +.job-filename { + font-family: 'Monaco', 'Menlo', 'Cascadia Code', 'Consolas', monospace; + font-size: 13px; + font-weight: 500; + line-height: 1.5; + word-break: break-word; + white-space: normal; + max-width: 100%; + color: var(--text-primary); +} + +/* Badges */ +.badge { + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + display: inline-block; + white-space: nowrap; +} + +.badge-finished { + background: rgba(16, 185, 129, 0.15); + color: var(--accent-success); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.badge-started, +.badge-processing { + background: rgba(124, 58, 237, 0.15); + color: var(--accent-primary); + border: 1px solid rgba(124, 58, 237, 0.3); +} + +.badge-queued { + background: rgba(234, 88, 12, 0.15); + color: var(--accent-warning); + border: 1px solid rgba(234, 88, 12, 0.3); +} + +.badge-error { + background: rgba(239, 68, 68, 0.15); + color: var(--accent-danger); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.badge-pending { + background: rgba(245, 158, 11, 0.15); + color: var(--accent-secondary); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +/* Date & Time */ +.datetime { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} + +.uid { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state i { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; + color: var(--accent-primary); +} + +/* Loading Spinner */ +.spinner { + width: 20px; + height: 20px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + display: inline-block; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 16px; + } + + .header { + flex-direction: column; + align-items: flex-start; + } + + .header-left { + flex-wrap: wrap; + } + + .header h1 { + font-size: 20px; + } + + .action-bar { + flex-direction: column; + align-items: stretch; + } + + .status-message { + margin-left: 0; + } +} \ No newline at end of file diff --git a/src/web.rs b/src/web.rs index 9d93e78..f672eef 100644 --- a/src/web.rs +++ b/src/web.rs @@ -12,6 +12,7 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; use tower_http::trace::TraceLayer; +use tower_http::services::ServeDir; #[derive(Clone)] pub struct AppState { @@ -99,6 +100,9 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> { .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)) + .nest_service("/static", ServeDir::new("src/static")) + .nest_service("/assets", ServeDir::new("assets")) .layer(TraceLayer::new_for_http()) .with_state(state); @@ -176,6 +180,34 @@ async fn get_status(State(state): State) -> Result) -> 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()) { + // Отправляем DELETE запрос для остановки задания + 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))) +} + // Error handling struct AppError(StatusCode, String); From fe13a5e3a728135aaa9db21b988444566ace9a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 17 Apr 2026 14:20:43 +0300 Subject: [PATCH 3/4] add WEB_PORT --- .env.example | 5 +- README.md | 420 +++++++++++++++++++++++++++++++++++++------------- src/config.rs | 8 +- src/web.rs | 7 +- 4 files changed, 331 insertions(+), 109 deletions(-) diff --git a/.env.example b/.env.example index 4b535ed..d3d296c 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,12 @@ NAS_USER="your_username" NAS_PASS="your_password" NAS_FILE="/Team Folder/path/to/file.osheet" -#Loging +# Logging RUST_LOG="info" +# Web Server +WEB_PORT="3000" + # Nexrender NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" OUTPUT_FOLDER="/path/to/output" diff --git a/README.md b/README.md index 05f9583..9b59e55 100644 --- a/README.md +++ b/README.md @@ -6,38 +6,56 @@

-[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) [![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) +[![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons) Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. +## Новое в версии 0.2.2 + +- **Веб-интерфейс**: Удобное управление заданиями через браузер +- **Мониторинг в реальном времени**: Отслеживание статуса всех заданий +- **Управление очередью**: Остановка, очистка и генерация заданий из веб-интерфейса +- **Тёмная/светлая тема**: Автоматическое определение системной темы +- **Сортировка и фильтрация**: Удобная работа с большим количеством заданий + ## Обзор -AE Anons — это CLI-утилита на Rust, которая выступает как **интеллектуальный генератор заданий** +AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий** для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга After Effects (лицензия MIT). -1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet) -2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах -3. Генерации заданий Nexrender с соответствующими шаблонами и ресурсами -4. Мониторинга завершения рендеринга и управления выходными файлами +### Режимы работы + +1. **Однократная обработка** (`--once` или без флагов): + - Подключение к NAS Synology + - Парсинг Excel данных + - Генерация и отправка заданий Nexrender + - Мониторинг завершения и выход + +2. **Веб-сервер** (`--web`): + - Запуск веб-интерфейса на порту `:3000` + - Управление заданиями через браузер + - Автообновление статуса каждую минуту ## Особенности - **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology - **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel - **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков -- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса -- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды -- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий -- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации -- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env` +- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий +- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов +- **Веб-интерфейс**: Удобное управление и мониторинг заданий +- **REST API**: Программный доступ к управлению заданиями +- **Профессиональное логирование**: Структурированный журнал с настройкой уровня ## Предварительные требования - Rust 1.70 или выше -- Доступ к NAS Synology с установленными и включенными пакетами File Station и Office +- Доступ к NAS Synology с пакетами File Station и Office - Экземпляр _server_ и _worker_(не менее одного) Nexrender -- Шаблоны After Effects, настроенные на _worker_ (формат Adobe After Effects 2024 .aepx) +- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx) ## Установка @@ -54,8 +72,6 @@ cd ae_anons cargo build --release ``` -Бинарный файл будет доступен по пути `target/release/ae_anons` - ### 3. Настройка окружения Скопируйте пример конфигурации и заполните своими данными: @@ -66,35 +82,47 @@ cp .env.example .env Затем отредактируйте файл .env: -```env -# Synology NAS -NAS_FQDN=https://your-nas.domain.com -NAS_USER=your_username -NAS_PASS=your_password -NAS_FILE=/team-folders/path/to/your/file.osheet - -# Nexrender -NEXRENDER_API_URL=http://your-nexrender-server:3000/api/v1/jobs -OUTPUT_FOLDER=//your-storage/path/to/output - -# Logging -RUST_LOG=info -``` - См. раздел Конфигурация для подробной информации. ## Использование -### Базовое выполнение +### Однократная обработка ```bash # Запуск с настройками по умолчанию ./target/release/ae_anons -# Запуск с пользовательским уровнем логирования +# Явно указать однократный режим +./target/release/ae_anons --once + +# С отладочным логированием RUST_LOG=debug ./target/release/ae_anons ``` +### Веб-сервер + +```bash +# Запуск веб-интерфейса на порту по умолчанию (3000) +./target/release/ae_anons --web + +# Или с указанием другого порта через .env файл +# WEB_PORT=8080 ./target/release/ae_anons --web + +# Сервер будет доступен по адресу http://localhost:3000 (или указанному порту) +## Веб-интерфейс +``` + +После запуска веб-сервера откройте браузер: + +- Главная страница: + +- API эндпоинты: + - `GET /api/jobs` - список всех заданий + - `POST /api/generate` - запуск генерации + - `POST /api/cleanup` - очистка завершённых + - `POST /api/jobs/stop-all` - остановка активных + - `GET /api/status` - статус сервера + ## Уровни логирования Управляйте детализацией вывода через переменную окружения `RUST_LOG`: @@ -121,12 +149,13 @@ RUST_LOG=debug cargo run | `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS | | `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender | | `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео | -| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) | | `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд | | `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды | | `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) | | `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) | | `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) | +| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) | +| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) | ### Пример файла `.env` @@ -137,9 +166,12 @@ NAS_USER="your_username" NAS_PASS="your_password" NAS_FILE="/Team Folder/path/to/file.osheet" -#Loging +# Logging RUST_LOG="info" +# Web Server +WEB_PORT="3000" + # Nexrender NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" OUTPUT_FOLDER="/path/to/output" @@ -183,7 +215,7 @@ TEMPLATE_OUTPUT_EXT="mp4" Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков. -| CHANEL | LINK | +| CHANELL | LINK | |---------|-----------------------------------------| | КАНАЛ | \\server\share\path\to\channel_logo.png | | TRIUMPH | \\server\share\path\to\triumph_logo.png | @@ -192,14 +224,17 @@ TEMPLATE_OUTPUT_EXT="mp4" Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах. -Обязательные колонки: DATA, TIME, CHANEL, SPORT, LEAGUE, TEAM A, TEAM B +Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B -**Примечание**: Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует! +**Примечания:** -| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANEL | TEAM A | TEAM B | -|--------|---------|-----------|-------|----------|---------------------------|---------|-----------------|-------------------| -| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 | -| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол | Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki | +- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует! +- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`). + +| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B | +|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------| +| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 | +| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki| ### Формат имен команд @@ -233,7 +268,8 @@ TEMPLATE_OUTPUT_EXT="mp4" YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4 ``` -Имена файлов транслитерируются в латиницу. Примеры: +Имена файлов транслитерируются в латиницу. +**Примеры:** - `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4` - `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4` @@ -253,35 +289,174 @@ YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4 ## Требования к шаблонам After Effects -Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоев: +Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв. -### Типы шаблонов +### Файлы шаблонов -| Файл шаблона | Назначение | -|------------------------|---------------------------------| -| `PackShot_DOUBLE.aepx` | Сопоставление с двумя командами | -| `PackShot_SINGLE.aepx` | Анонсы для одной команды | +Настраиваются через переменные окружения: -### Обязательные слои +| Переменная | Назначение | Пример значения | +|-------------------------|-------------------------------------|------------------------------------| +| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` | +| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` | +| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` | +| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` | +| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` | -| Имя слоя | Тип | Описание | -|------------------------|-------|-------------------------------------------------| -| DATA | Text | Отображение даты (автоматически подстраивается) | -| TIME / TIME_H / TIME_M | Text | Отображение времени | -| LEAGUE | Text | Название лиги | -| SPORT | Text | Категория спорта | -| TEAMS | Text | Скомбинированные имена команд | -| TEAM_A_LOGO | Image | Логотип домашней команды | -| TEAM_B_LOGO | Image | Логотип гостевой команды | -| CHANEL | Image | Логотип канала | -| TOP | Video | Наложение видео оформления | +### Обязательные слои в шаблонах + +#### Текстовые слои + +| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание | +|-----------|:-------------:|:-------------:|-------------------------------------------------| +| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) | +| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) | +| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) | +| `TIME` | ❌ | ✅ | Полное время (единый слой) | +| `LEAGUE` | ✅ | ✅ | Название лиги/турнира | +| `SPORT` | ✅ | ✅ | Категория спорта | +| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд | + +#### Слои с изображениями + +| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание | +|----------------|:-------------:|:-------------:|-----------------------------| +| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A | +| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B | +| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)| + +#### Видео слои + +| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание | +|-----------|:-------------:|:-------------:|----------------------------| +| `TOP` | ✅ | ✅ | Наложение видео оформления | + +### Особенности шаблонов + +#### Шаблон DOUBLE (две команды) + +Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`. + +Обязательные слои: + +- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME` +- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO` + +#### Шаблон SINGLE (одна команда) + +Используется когда заполнено только одно поле команды. + +Обязательные слои: + +- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M` +- Только логотип `TEAM_A_LOGO` + +### Автоматические корректировки + +AE Anons автоматически применяет следующие настройки к слоям: + +#### Слой DATA + +В зависимости от отображаемого текста и типа шаблона: + +| Текст | Шаблон | Font Size | Anchor Point | +|------------|----------|-----------|--------------| +| "сегодня" | DOUBLE | 105 | [0, 5] | +| "завтра" | DOUBLE | 115 | [0, 25] | +| дата (<6) | DOUBLE | 120 | [0, 20] | + +#### Слои TIME_H, TIME_M, TIME + +Корректировка Anchor Point в зависимости от длины текста: + +| Условие | Anchor Point | +|-------------------|--------------| +| 1 символ | [60, 0] | +| 2 символа, <20 | [20, 0] | + +#### Слой LEAGUE + +Если длина текста превышает 16 символов, размер шрифта уменьшается до 73. + +#### Слой TEAMS + +Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55. + +#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO) + +Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования: + +```javascript +if (width > height) { + max_size = width; +} else { + max_size = height; +} +var real_size = 550 / max_size * 100; +[real_size, real_size] +``` + +### Пример структуры слоёв в After Effects + +Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов. + +```text +📁 main (композиция) +├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный) +├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный) +├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный) +├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный) +├── 📝 DATA (текстовый слой) # Дата (обязательный) +├── 📝 TIME (текстовый слой) # Время (только SINGLE) +├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE) +├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE) +├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный) +├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный) +├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE) +└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально) +``` + +### Формат проекта и совместимость с After Effects + +**Поддерживаемые форматы проектов:** + +- `.aep` — стандартный бинарный формат After Effects (рекомендуется) +- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015) + +**Совместимость версий After Effects:** + +| Версия After Effects | Поддерживаемые форматы | Особенности | +|----------------------|------------------------|-------------------------------------------------| +| CS 5.5 | `.aep` | Базовая поддержка | +| CC / CC 2014 | `.aep` | Полная поддержка | +| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` | +| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` | +| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)| + +> **⚠️ Важно для After Effects 2023+:** +> +> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию. +> +> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона. ### Настройки композиции -- Имя композиции: `pack` -- Выходной модуль: `Start_h264` -- Формат вывода: `mp4` -- Формат проекта: Adobe After Effects 2024 .aepx +| Параметр | Переменная окружения | Пример значения | Описание | +|---------------------------|--------------------------|-----------------|---------------------------------------------| +| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE | +| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE | +| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла | + +**Рекомендации по выбору формата:** + +1. Используйте `.aep` для максимальной совместимости со всеми версиями AE +2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект +3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE` + +**Источники:** + +- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with) +- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html) ## Устранение неисправностей @@ -325,7 +500,6 @@ RUST_LOG=debug ./target/release/ae_anons - **Большие таблицы**: Ограничение обработки до 10,000 строк на лист - **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов - **Параллельные задания**: Nexrender управляет очередью заданий внутренне -- **Использование памяти**: Парсинг Excel сохраняет всю книгу в памяти ## Примечания по безопасности @@ -340,45 +514,60 @@ RUST_LOG=debug ./target/release/ae_anons ```shell ae_anons/ +├── .gitignore +├── .env.example ├── Cargo.toml ├── LICENSE ├── README.md -├── .env.example -├── src/ -│ ├── main.rs -│ ├── config.rs -│ ├── nexrender.rs -│ ├── synology.rs -│ ├── processor.rs -│ └── web.rs -├── static/ -│ ├── index.html -│ └── style.css -└── assets/ - └── logo.png - +├── assets/ +│ └── logo.png +└── src/ + ├── main.rs # Точка входа, CLI + ├── config.rs # Конфигурация из .env + ├── nexrender.rs # Модели заданий Nexrender + ├── synology.rs # Клиент Synology API + ├── processor.rs # Логика обработки + ├── web.rs # Веб-сервер и API + └── static/ + ├── index.html # Веб-интерфейс + └── style.css # Стили ``` ## Зависимости +### Основные зависимости + +| Crate | Версия | Назначение | +|--------------------|---------|-----------------------------------------| +| reqwest | 0.12 | HTTP клиент для коммуникации с API | +| serde / serde_json | 1.0 | Сериализация и десериализация JSON | +| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) | +| chrono | 0.4 | Обработка дат и времени | +| tokio | 1.0 | Асинхронная среда выполнения | +| dotenv | 0.15 | Загрузка конфигурации из .env файла | +| log / env_logger | 0.4/0.11| Система логирования с уровнями | +| thiserror | 2.0 | Эргономичные определения типов ошибок | +| anyhow | 1.0 | Упрощённая обработка ошибок | +| regex | 1.11 | Регулярные выражения | +| urlencoding | 2.1 | Кодирование URL для API запросов | +| bytes | 1.9 | Работа с байтовыми данными | +| futures | 0.3 | Асинхронные примитивы | + +### Веб-сервер и CLI + | Crate | Версия | Назначение | |--------------------|--------|-----------------------------------------| -| reqwest | 0.12 | HTTP клиент для коммуникации API | -| serde / serde_json | 1.0 | Сериализация JSON | -| calamine | 0.26 | Парсинг файлов Excel | -| chrono | 0.4 | Обработка дат и времени | -| tokio | 1.0 | Асинхронная среда выполнения | -| dotenv | 0.15 | Конфигурация через переменные окружения | -| log / env_logger | 0.11 | Инфраструктура логирования | -| thiserror | 2.0 | Определение типов ошибок | -| anyhow | 1.0 | Обработка ошибок | +| axum | 0.8 | Веб-фреймворк для REST API | +| tower | 0.5 | Промежуточное ПО для веб-сервера | +| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)| +| askama | 0.15 | Шаблонизация (опционально) | +| clap | 4.5 | Парсинг аргументов командной строки | -### Планируемые новые возможности +### Платформозависимые зависимости -1. Расширить `JobData` в `nexrender.rs` для новых полей данных -2. Обновить логику парсинга листов при необходимости добавления новых колонок -3. Добавить соответствующие слои After Effects в шаблоны -4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов +| Crate | Версия | Платформа | Назначение | +|--------------------|--------|-----------|----------------------------------| +| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)| ## 🙏 Благодарности @@ -388,7 +577,7 @@ ae_anons/ ## Лицензия -**AE Anons** — [MIT License](LICENSE) +**AE Anons** — [License](LICENSE) **Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE) Обе лицензии MIT обеспечивают полную свободу использования и модификации кода. @@ -413,20 +602,43 @@ ae_anons/ ## История изменений -### v0.1.0 +### v0.2.2 (текущая) -- Первый выпуск -- Интеграция с Synology Office -- Базовая генерация заданий Nexrender -- Парсинг Excel с динамическим обнаружением листов -- Создание множественных вариантов -- Автоматические настройки размера шрифта и позиции -- Умное масштабирование логотипов по целевому размеру +- Исправлено отображение имён выходных файлов в веб-интерфейсе + +- Улучшена цветовая схема + +- Добавлена сортировка по всем колонкам + +- Вынесены стили в отдельный CSS файл + +### v0.2.0 + +- Добавлен веб-интерфейс для управления заданиями + +- Реализован REST API + +- Добавлена поддержка тёмной/светлой темы + +- Автообновление статуса заданий + +- Возможность остановки всех активных заданий ### v0.1.1 -- Оптимизированна работат с памятью -- Убрана функция создания `.json` +- Оптимизирована работа с памятью + +- Убрана функция создания .json + +### v0.1.0 + +- Первый выпуск + +- Интеграция с Synology Office + +- Базовая генерация заданий Nexrender + +- Парсинг Excel с динамическим обнаружением листов --- diff --git a/src/config.rs b/src/config.rs index a43e492..53993da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct Config { pub template_composition: String, pub template_output_module: String, pub template_output_ext: String, + //web_serwer + pub web_port: u16, } impl Config { @@ -42,6 +44,10 @@ impl Config { .context("TEMPLATE_OUTPUT_MODULE not set")?, template_output_ext: env::var("TEMPLATE_OUTPUT_EXT") .context("TEMPLATE_OUTPUT_EXT not set")?, + web_port: env::var("WEB_PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse() + .context("Invalid WEB_PORT")?, }) } -} \ No newline at end of file +} diff --git a/src/web.rs b/src/web.rs index f672eef..56f2903 100644 --- a/src/web.rs +++ b/src/web.rs @@ -11,8 +11,8 @@ use serde::Serialize; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::Mutex; -use tower_http::trace::TraceLayer; use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; #[derive(Clone)] pub struct AppState { @@ -88,7 +88,9 @@ impl JobInfo { } } +// web.rs - функция run_web_server pub async fn run_web_server(config: Config) -> anyhow::Result<()> { + let web_port = config.web_port; // Берём порт из конфига let state = AppState { config, last_generation: Arc::new(Mutex::new(None)), @@ -106,7 +108,7 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> { .layer(TraceLayer::new_for_http()) .with_state(state); - let addr: SocketAddr = "0.0.0.0:3000".parse()?; + 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?; @@ -114,7 +116,6 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> { Ok(()) } -// Остальные обработчики остаются без изменений... async fn index_page() -> Html<&'static str> { Html(include_str!("static/index.html")) } From 48f32e63c2d16ce00b299b1916f8ff96c393c8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 17 Apr 2026 14:38:38 +0300 Subject: [PATCH 4/4] feat: release v0.2.2 - web interface with configurable port --- .env.example | 8 ++++---- README.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index d3d296c..34e2330 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,10 @@ NAS_USER="your_username" NAS_PASS="your_password" NAS_FILE="/Team Folder/path/to/file.osheet" -# Logging +# Logging (optional, defaults to info) RUST_LOG="info" -# Web Server +# Web Server (optional, defaults to 3000) WEB_PORT="3000" # Nexrender @@ -15,8 +15,8 @@ NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" OUTPUT_FOLDER="/path/to/output" # After Effects Templates -TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx" -TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx" +TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep" +TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep" TEMPLATE_COMPOSITION="main" TEMPLATE_OUTPUT_MODULE="h264" TEMPLATE_OUTPUT_EXT="mp4" \ No newline at end of file diff --git a/README.md b/README.md index 9b59e55..5a37a0b 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,10 @@ RUST_LOG=debug ./target/release/ae_anons # Или с указанием другого порта через .env файл # WEB_PORT=8080 ./target/release/ae_anons --web -# Сервер будет доступен по адресу http://localhost:3000 (или указанному порту) -## Веб-интерфейс ``` +## Веб-интерфейс + После запуска веб-сервера откройте браузер: - Главная страница: