2 Commits

Author SHA1 Message Date
bb371e1b71 v0.2.2 2026-04-17 12:54:43 +03:00
8721353b8d v0.2.0 2026-04-17 11:58:16 +03:00
9 changed files with 1971 additions and 471 deletions

229
Cargo.lock generated
View File

@@ -10,12 +10,15 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ae_anons" name = "ae_anons"
version = "0.1.1" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama",
"axum",
"bytes", "bytes",
"calamine", "calamine",
"chrono", "chrono",
"clap",
"dotenv", "dotenv",
"env_logger", "env_logger",
"futures", "futures",
@@ -27,6 +30,8 @@ dependencies = [
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "tokio",
"tower",
"tower-http",
"urlencoding", "urlencoding",
] ]
@@ -113,6 +118,58 @@ dependencies = [
"derive_arbitrary", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -125,12 +182,73 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -199,6 +317,46 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "codepage" name = "codepage"
version = "0.1.2" version = "0.1.2"
@@ -519,6 +677,12 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -552,12 +716,24 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -572,6 +748,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -869,6 +1046,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -1199,6 +1382,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -1338,6 +1527,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1400,6 +1600,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -1575,6 +1781,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1585,14 +1792,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1613,6 +1830,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-core", "tracing-core",
] ]
@@ -1940,6 +2158,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ae_anons" name = "ae_anons"
version = "0.1.1" version = "0.2.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
license-file = "LICENSE" license-file = "LICENSE"
@@ -28,6 +28,13 @@ log = "0.4"
env_logger = "0.11" env_logger = "0.11"
bytes = "1.9" 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] [dev-dependencies]
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] } tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }

View File

@@ -342,15 +342,21 @@ RUST_LOG=debug ./target/release/ae_anons
ae_anons/ ae_anons/
├── Cargo.toml ├── Cargo.toml
├── LICENSE ├── LICENSE
├── assets/
│ └── logo.png
├── README.md ├── README.md
├── .env.example ├── .env.example
── src/ ── src/
├── main.rs # Точка входа и оркестрация приложения ├── main.rs
├── config.rs # Управление конфигурацией ├── config.rs
├── nexrender.rs # Генерация заданий Nexrender и структура данных ├── nexrender.rs
── synology.rs # Клиент API Synology ── synology.rs
│ ├── processor.rs
│ └── web.rs
├── static/
│ ├── index.html
│ └── style.css
└── assets/
└── logo.png
``` ```
## Зависимости ## Зависимости

View File

@@ -1,480 +1,48 @@
mod config; mod config;
mod nexrender; mod nexrender;
mod processor;
mod synology; mod synology;
mod web;
use anyhow::{anyhow, Result}; use anyhow::Result;
use calamine::{Data, Reader, Xlsx}; use clap::Parser;
use chrono::{Duration, NaiveDate};
use config::Config; use config::Config;
use log::{debug, error, info}; use log::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;
// Структуры для in-memory данных #[derive(Parser)]
#[derive(Debug, Clone)] #[command(author, version, about, long_about = None)]
pub struct SheetData { struct Cli {
pub name: String, /// Run web server instead of one-time processing
pub headers: Vec<String>, #[arg(short, long)]
pub rows: Vec<HashMap<String, String>>, web: bool,
}
#[derive(Debug, Default)] /// Run one-time processing (default if no flags)
pub struct ExcelWorkbook { #[arg(short, long)]
pub sheets: Vec<SheetData>, once: bool,
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 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()?; 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<ExcelWorkbook> {
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<ExcelWorkbook> {
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<Data>,
) -> Result<SheetData> {
// Предварительно выделяем память для избежания реаллокаций
let (row_count, col_count) = range.get_size();
let mut data_matrix: Vec<Vec<String>> = 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<String> = 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<HashMap<String, String>> = 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 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<String, String> = 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<String, String> = 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<JobData> = 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);
}
}
}
if cli.web {
info!( info!(
"Successfully submitted {} jobs to Nexrender", "Starting AE Anons web server v{}",
submitted_jobs.len() env!("CARGO_PKG_VERSION")
); );
web::run_web_server(config).await?;
if !submitted_jobs.is_empty() {
info!("Monitoring job completion...");
monitor_jobs(&http_client, &config.nexrender_api_url, submitted_jobs).await?;
}
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<Value> = 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 { } else {
remaining.push((uid, outname)); // По умолчанию или с флагом --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());
} }
}
pending = remaining;
if !pending.is_empty() {
info!("Waiting for {} jobs to complete...", pending.len());
}
}
info!("All jobs completed successfully");
Ok(()) Ok(())
} }

View File

@@ -302,7 +302,6 @@ impl JobData {
variants variants
} }
// ЕДИНСТВЕННАЯ реализация метода
pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob { pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob {
let template = if self.team_b.is_empty() { let template = if self.team_b.is_empty() {
Template::single( Template::single(

431
src/processor.rs Normal file
View File

@@ -0,0 +1,431 @@
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;
#[derive(Debug, Clone)]
pub struct SheetData {
pub name: String,
pub headers: Vec<String>,
pub rows: Vec<HashMap<String, String>>,
}
#[derive(Debug, Default)]
pub struct ExcelWorkbook {
pub sheets: Vec<SheetData>,
}
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<Vec<(String, String)>> {
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<ExcelWorkbook> {
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<ExcelWorkbook> {
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<Data>,
) -> Result<SheetData> {
let (row_count, col_count) = range.get_size();
let mut data_matrix: Vec<Vec<String>> = 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<String> = 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<HashMap<String, String>> = 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<Vec<(String, String)>> {
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<String, String> = 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<String, String> = 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<JobData> = 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<Value> = 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<Vec<Value>> {
let client = Client::new();
let response = client.get(api_url).send().await?;
if response.status().is_success() {
let jobs: Vec<Value> = response.json().await?;
Ok(jobs)
} else {
Ok(Vec::new())
}
}

489
src/static/index.html Normal file
View File

@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons - Nexrender Job Manager</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-container">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
<i class="fas fa-bolt"></i>
</div>
</div>
<h1>AE Anons - Nexrender Job Manager</h1>
</div>
<div class="header-controls">
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-circle-half-stroke" id="themeIcon"></i>
<span id="themeText">Auto</span>
</button>
</div>
</div>
<!-- Action Bar -->
<div class="action-bar">
<button class="btn btn-primary" onclick="generateJobs()">
<i class="fas fa-play"></i> Generate New Jobs
</button>
<button class="btn btn-warning" onclick="stopAllJobs()">
<i class="fas fa-stop"></i> Stop All Jobs
</button>
<button class="btn btn-danger" onclick="cleanupJobs()">
<i class="fas fa-trash-alt"></i> Cleanup Finished
</button>
<button class="btn btn-outline" onclick="refreshJobs()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<div class="status-message" id="statusMessage">
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
<span>Ready</span>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid" id="statsGrid"></div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
onkeyup="filterTable()">
</div>
<div class="auto-refresh-badge">
<i class="fas fa-clock"></i>
<span>Auto-refresh: 60s</span>
<span id="refreshCountdown">60</span>s
</div>
</div>
<!-- Table -->
<div class="table-container">
<table id="jobsTable">
<thead>
<tr>
<th data-column="filename" onclick="sortTable('filename')">
Output File <i class="fas fa-sort"></i>
</th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
State <i class="fas fa-sort-up"></i>
</th>
<th data-column="created" onclick="sortTable('created')">
Created <i class="fas fa-sort"></i>
</th>
<th data-column="updated" onclick="sortTable('updated')">
Updated <i class="fas fa-sort"></i>
</th>
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
// State
let allJobs = [];
let filteredJobs = [];
let isLoading = false;
let autoRefreshTimer = null;
let countdownTimer = null;
let countdownValue = 60;
let currentTheme = 'auto';
// Sorting state
let currentSort = { column: 'state', direction: 'asc' };
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
// Theme handling
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function getOSPreference() {
return darkModeMediaQuery.matches ? 'dark' : 'light';
}
function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
updateThemeButton(theme);
}
function updateThemeButton(theme) {
const icon = document.getElementById('themeIcon');
const text = document.getElementById('themeText');
if (theme === 'auto') {
icon.className = 'fas fa-circle-half-stroke';
text.textContent = 'Auto';
} else if (theme === 'dark') {
icon.className = 'fas fa-moon';
text.textContent = 'Dark';
} else {
icon.className = 'fas fa-sun';
text.textContent = 'Light';
}
}
function toggleTheme() {
if (currentTheme === 'auto') {
currentTheme = 'light';
} else if (currentTheme === 'light') {
currentTheme = 'dark';
} else {
currentTheme = 'auto';
}
localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme);
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'auto';
currentTheme = savedTheme;
applyTheme(currentTheme);
darkModeMediaQuery.addEventListener('change', (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
// Sorting
function sortTable(column) {
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
updateSortIcons();
sortAndRender();
}
function updateSortIcons() {
document.querySelectorAll('th').forEach(th => {
const column = th.dataset.column;
th.classList.remove('sorted-asc', 'sorted-desc');
const icon = th.querySelector('i');
if (column === currentSort.column) {
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
icon.className = currentSort.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
} else {
icon.className = 'fas fa-sort';
}
});
}
function sortJobs(jobs) {
const { column, direction } = currentSort;
const multiplier = direction === 'asc' ? 1 : -1;
return [...jobs].sort((a, b) => {
let aVal, bVal;
switch (column) {
case 'filename':
aVal = a.outfile_name || '';
bVal = b.outfile_name || '';
return multiplier * aVal.localeCompare(bVal);
case 'state':
aVal = stateOrder[a.state] || 999;
bVal = stateOrder[b.state] || 999;
return multiplier * (aVal - bVal);
case 'created':
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'updated':
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'uid':
aVal = a.uid || '';
bVal = b.uid || '';
return multiplier * aVal.localeCompare(bVal);
default:
return 0;
}
});
}
function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
filteredJobs : allJobs;
const sorted = sortJobs(jobsToRender);
renderJobs(sorted);
}
// Data fetching
async function refreshJobs() {
if (isLoading) return;
isLoading = true;
try {
setStatus('loading', 'Loading jobs...');
const response = await fetch('/api/jobs');
allJobs = await response.json();
filteredJobs = [];
document.getElementById('filterInput').value = '';
sortAndRender();
updateStats();
setStatus('success', `Loaded ${allJobs.length} jobs`);
} catch (err) {
console.error(err);
setStatus('error', 'Failed to load jobs');
} finally {
isLoading = false;
}
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
if (jobs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>No jobs found</p>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at);
return `
<tr>
<td>
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
</td>
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td>
<td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
</tr>
`;
}).join('');
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStateClass(state) {
const classes = {
'finished': 'badge-finished',
'started': 'badge-started',
'processing': 'badge-processing',
'queued': 'badge-queued',
'error': 'badge-error',
'pending': 'badge-pending'
};
return classes[state] || 'badge-pending';
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
return `${time}, ${dayMonth}`;
} catch {
return dateStr;
}
}
function updateStats() {
const stats = {
total: allJobs.length,
finished: 0,
started: 0,
queued: 0,
error: 0,
other: 0
};
allJobs.forEach(job => {
switch (job.state) {
case 'finished': stats.finished++; break;
case 'started':
case 'processing': stats.started++; break;
case 'queued': stats.queued++; break;
case 'error': stats.error++; break;
default: stats.other++; break;
}
});
const grid = document.getElementById('statsGrid');
grid.innerHTML = `
<div class="stat-card">
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
<div class="value">${stats.total}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-check-circle"></i> Completed</h3>
<div class="value">${stats.finished}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-play-circle"></i> Active</h3>
<div class="value">${stats.started + stats.queued}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-exclamation-circle"></i> Errors</h3>
<div class="value">${stats.error}</div>
</div>
`;
}
function filterTable() {
const filter = document.getElementById('filterInput').value.toLowerCase();
filteredJobs = allJobs.filter(job =>
job.outfile_name.toLowerCase().includes(filter) ||
job.uid.toLowerCase().includes(filter)
);
sortAndRender();
}
// Actions
async function generateJobs() {
setStatus('loading', 'Generating jobs...');
try {
const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Job generation started');
setTimeout(() => refreshJobs(), 5000);
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
async function stopAllJobs() {
if (!confirm('Are you sure you want to stop all active jobs?')) return;
setStatus('loading', 'Stopping all jobs...');
try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
if (response.ok) {
setStatus('success', 'All jobs stopped');
refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
async function cleanupJobs() {
setStatus('loading', 'Cleaning up finished jobs...');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Cleanup completed');
await refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
function setStatus(type, message) {
const el = document.getElementById('statusMessage');
const icons = {
loading: '<span class="spinner"></span>',
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
};
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
}
// Auto-refresh
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
}, 1000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (countdownTimer) clearInterval(countdownTimer);
}
function resetCountdown() {
countdownValue = 60;
document.getElementById('refreshCountdown').textContent = countdownValue;
}
// Initialize
initTheme();
refreshJobs();
startAutoRefresh();
updateSortIcons();
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
</script>
</body>
</html>

555
src/static/style.css Normal file
View File

@@ -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;
}
}

218
src/web.rs Normal file
View File

@@ -0,0 +1,218 @@
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;
use tower_http::services::ServeDir;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
}
#[derive(Serialize)]
pub struct JobInfo {
pub uid: String,
pub outfile_name: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl JobInfo {
fn from_nexrender_json(job: &serde_json::Value) -> Option<Self> {
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))
.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);
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<AppState>) -> Result<Json<Vec<JobInfo>>, 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<JobInfo> = jobs_json
.iter()
.filter_map(JobInfo::from_nexrender_json)
.collect();
Ok(Json(jobs))
}
async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
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<AppState>) -> Result<impl IntoResponse, AppError> {
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<AppState>) -> Result<Json<serde_json::Value>, 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<AppState>) -> Result<impl IntoResponse, AppError> {
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);
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
(self.0, self.1).into_response()
}
}