diff --git a/.env.example b/.env.example
index 4b535ed..34e2330 100644
--- a/.env.example
+++ b/.env.example
@@ -4,16 +4,19 @@ NAS_USER="your_username"
NAS_PASS="your_password"
NAS_FILE="/Team Folder/path/to/file.osheet"
-#Loging
+# Logging (optional, defaults to info)
RUST_LOG="info"
+# Web Server (optional, defaults to 3000)
+WEB_PORT="3000"
+
# Nexrender
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/Cargo.lock b/Cargo.lock
index 202edea..2b23e24 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10,12 +10,15 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ae_anons"
-version = "0.1.1"
+version = "0.2.2"
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..a802007 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "ae_anons"
-version = "0.1.1"
+version = "0.2.2"
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/README.md b/README.md
index 0f8f464..5a37a0b 100644
--- a/README.md
+++ b/README.md
@@ -6,38 +6,56 @@
-[](https://opensource.org/licenses/MIT) [](https://www.rust-lang.org/) [](https://git.tvstart.ru/lexx/AE_Anons)
+[](https://opensource.org/licenses/MIT)
+[](https://www.rust-lang.org/)
+[](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
+
+```
+
+## Веб-интерфейс
+
+После запуска веб-сервера откройте браузер:
+
+- Главная страница:
+
+- 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,39 +514,60 @@ RUST_LOG=debug ./target/release/ae_anons
```shell
ae_anons/
+├── .gitignore
+├── .env.example
├── Cargo.toml
├── LICENSE
+├── README.md
├── assets/
│ └── logo.png
-├── README.md
-├── .env.example
└── src/
- ├── main.rs # Точка входа и оркестрация приложения
- ├── config.rs # Управление конфигурацией
- ├── nexrender.rs # Генерация заданий Nexrender и структура данных
- └── synology.rs # Клиент API Synology
+ ├── 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)|
## 🙏 Благодарности
@@ -382,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 обеспечивают полную свободу использования и модификации кода.
@@ -407,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/main.rs b/src/main.rs
index 3a1fbd5..0a553ab 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,480 +1,48 @@
mod config;
mod nexrender;
+mod processor;
mod synology;
+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()
- } 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()
+ if cli.web {
+ info!(
+ "Starting AE Anons web server v{}",
+ env!("CARGO_PKG_VERSION")
);
- }
-}
-
-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?;
+ web::run_web_server(config).await?;
+ } else {
+ // По умолчанию или с флагом --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/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
new file mode 100644
index 0000000..90ad180
--- /dev/null
+++ b/src/processor.rs
@@ -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,
+ 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..3e8a5c4
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,489 @@
+
+
+
+
+
+
+ AE Anons - Nexrender Job Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Auto-refresh: 60s
+ 60s
+
+
+
+
+
+
+
+
+ |
+ Output File
+ |
+
+ State
+ |
+
+ Created
+ |
+
+ Updated
+ |
+
+ UID
+ |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 0000000..56f2903
--- /dev/null
+++ b/src/web.rs
@@ -0,0 +1,219 @@
+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::services::ServeDir;
+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()),
+ })
+ }
+}
+
+// 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)),
+ };
+
+ 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 = format!("0.0.0.0:{}", web_port).parse()?;
+ log::info!("Web server listening on http://{}", addr);
+
+ let listener = tokio::net::TcpListener::bind(addr).await?;
+ axum::serve(listener, app).await?;
+ Ok(())
+}
+
+async fn 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))
+}
+
+async fn stop_all_jobs(State(state): State) -> Result {
+ use reqwest::Client;
+
+ let client = Client::new();
+ let api_url = &state.config.nexrender_api_url;
+
+ // Получаем все задания
+ let jobs = fetch_all_jobs(api_url)
+ .await
+ .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ let mut stopped = 0;
+ for job in jobs {
+ let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
+ if state == "queued" || state == "started" || state == "processing" {
+ if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
+ // Отправляем 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()
+ }
+}