22 Commits

Author SHA1 Message Date
6fcc518438 Всё ещё не работает 2026-05-06 12:03:25 +03:00
02fd7a65a7 не работает но компилируется 2026-04-20 15:13:28 +03:00
855bdcc3f9 небольшие правки 2026-04-18 11:21:11 +03:00
4646dd2b70 fix: исправлены роуты веб-сервера и обновлён Makefile
- Убран дублирующийся роут /favicon.ico
- Добавлен импорт ServeDir
- Makefile: поддержка ImageMagick 6 и 7
- Добавлена цель favicon
2026-04-18 11:12:54 +03:00
6a642beb5c 0.2.6 2026-04-17 20:06:58 +03:00
9b3d959ce3 feat: сортировка заданий по дате и времени, последовательная отправка
- Добавлена сортировка заданий по дате и времени события (самые ранние — первыми)
- Добавлена сортировка по варианту (оригинал -> today -> tomorrow)
- Изменена отправка на последовательную для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2+ дефиса -> 1)
- Исправлен SINGLE шаблон (убран слой TEAMS)
- Добавлена поддержка cache: true/false для ассетов
- Логотипы команд: cache: false (часто меняются)
- Логотип канала: cache: true (редко меняется)
- Видео-пак: cache: true (редко меняется)
- Версия 0.2.6
2026-04-17 20:00:06 +03:00
e06b55ad07 Оптимизирована работа с памятью 2026-04-17 19:14:38 +03:00
c3056010eb v0.2.4 2026-04-17 18:33:40 +03:00
9929dab66f feat: групповая отправка заданий по 3 для сохранения порядка в очереди
- Отправка заданий группами по 3 (оригинал + today + tomorrow) параллельно
- Сохранение порядка внутри группы для корректной FIFO очереди Nexrender
- Добавлен UID в логи отправки заданий
- Версия обновлена до 0.2.4
2026-04-17 18:29:04 +03:00
6f13679dd9 feat: fully offline web interface with embedded Font Awesome
- Replace CDN with local Font Awesome files
- Add favicon handler
- Embed all static assets (CSS, fonts, logo)
- Update README with Font Awesome license attribution
- Bump version to 0.2.3
2026-04-17 18:16:20 +03:00
70d3e6c6cd Убранно лишнее 2026-04-17 17:33:01 +03:00
e866cb32b7 стилистические правки 2026-04-17 17:30:23 +03:00
11a8cb7246 Добавлены скомпилированные релизы 2026-04-17 17:27:14 +03:00
dc50413339 Small fix 2026-04-17 17:03:57 +03:00
56f04804cc v 0.2.3 2026-04-17 16:35:46 +03:00
0f2fe50eab chore: bump version to 0.2.3 2026-04-17 16:29:03 +03:00
fbcb619544 улучшено debug-логирование и обработка путей
- Добавлен детальный вывод при парсинге листа TEAMS
- Добавлен вывод поиска логотипов и сводка по отсутствующим
- Исправлен порядок инициализации RUST_LOG
- Добавлена поддержка smb:// путей в unc_to_uri
2026-04-17 16:00:46 +03:00
0b2091b1e6 Release v0.2.2 - Web Interface 2026-04-17 14:39:27 +03:00
48f32e63c2 feat: release v0.2.2 - web interface with configurable port 2026-04-17 14:38:38 +03:00
fe13a5e3a7 add WEB_PORT 2026-04-17 14:20:43 +03:00
bb371e1b71 v0.2.2 2026-04-17 12:54:43 +03:00
8721353b8d v0.2.0 2026-04-17 11:58:16 +03:00
21 changed files with 13975 additions and 868 deletions

View File

@@ -1,19 +1,32 @@
# Synology NAS # Synology NAS
NAS_FQDN="https://your-nas.example.com" NAS_FQDN="https://nas.company.local:5001"
NAS_USER="your_username" NAS_USER="DOMAIN\\service_account"
NAS_PASS="your_password" NAS_PASS="strongpassword"
NAS_FILE="/Team Folder/path/to/file.osheet" NAS_FILE="/Team Folder/Anonsy/sport.osheet"
#Loging # Logging
RUST_LOG="info" RUST_LOG="info"
# Web Server
WEB_PORT="3000"
# Nexrender # Nexrender
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
OUTPUT_FOLDER="/path/to/output" OUTPUT_FOLDER="/mnt/nexrender/output"
# After Effects Templates # After Effects Templates
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx" TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx" TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
TEMPLATE_COMPOSITION="main" TEMPLATE_COMPOSITION="main"
TEMPLATE_OUTPUT_MODULE="h264" TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4" TEMPLATE_OUTPUT_EXT="mp4"
# SMB Upload for approved videos (optional, only needed for web mode)
# Format: smb://server/share/optional/folder
SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
SMB_USERNAME="DOMAIN\\service_account"
SMB_PASSWORD="strongpassword"
# Optional: SMB_WORKGROUP="DOMAIN"
# Database (optional, default sqlite:ae_anons.db)
DATABASE_URL="sqlite:ae_anons.db"

3
.gitignore vendored
View File

@@ -1,7 +1,4 @@
.DS_Store .DS_Store
*.xlsx
*.txt
makefile
# Файлы окружения # Файлы окружения
.env .env

2878
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
[package] [package]
name = "ae_anons" name = "ae_anons"
version = "0.1.1" version = "0.3.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
license-file = "LICENSE"
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"] authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
description = "Automated Nexrender job generator from Synology Office spreadsheets" description = "Automated Nexrender job generator from Synology Office spreadsheets"
repository = "https://git.tvstart.ru/lexx/AE_Anons" repository = "https://git.tvstart.ru/lexx/AE_Anons"
@@ -12,24 +11,41 @@ keywords = ["nexrender", "after-effects", "synology", "automation"]
categories = ["command-line-utilities", "multimedia"] categories = ["command-line-utilities", "multimedia"]
[dependencies] [dependencies]
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } reqwest = { version = "0.12.5", features = ["json", "multipart", "stream"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0.140"
thiserror = "2.0" thiserror = "2.0.12"
urlencoding = "2.1" urlencoding = "2.1.3"
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.52.1", features = ["full", "sync"] }
dotenv = "0.15" dotenv = "0.15.0"
calamine = "0.26" calamine = "0.34"
chrono = "0.4" chrono = { version = "0.4.41", features = ["serde"] }
regex = "1.11" regex = "1.11.1"
futures = "0.3" futures = "0.3.31"
anyhow = "1.0" anyhow = "1.0.98"
log = "0.4" log = "0.4.27"
env_logger = "0.11" env_logger = "0.11.8"
bytes = "1.9" bytes = "1.10.1"
sqlx = { version = "0.8.5", features = [
"runtime-tokio-native-tls",
"sqlite",
"chrono",
] }
tokio-stream = "0.1.17"
mime_guess = "2.0.5"
tokio-util = "0.7.14"
url = "2.5.0"
smb = "0.11.1"
# Web server (with WebSocket support)
axum = { version = "0.8.9", features = ["ws"] }
tower = "0.5.2"
tower-http = { version = "0.6.4", features = ["fs", "trace"] }
askama = "0.15.4"
clap = { version = "4.5.38", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] } tokio = { version = "1.52.1", features = ["full", "rt-multi-thread"] }
[target.x86_64-unknown-linux-gnu.dependencies] [target.x86_64-unknown-linux-gnu.dependencies]
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
@@ -42,3 +58,9 @@ opt-level = "z"
lto = true lto = true
codegen-units = 1 codegen-units = 1
strip = true strip = true
[target.'cfg(windows)'.dependencies]
winres = "0.1"
[build-dependencies]
winres = "0.1"

470
README.md
View File

@@ -1,4 +1,4 @@
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects # AE Anons - Автономный генератор спортивных анонсов в After Effects
<!-- markdownlint-disable MD033 --> <!-- markdownlint-disable MD033 -->
<p align="left"> <p align="left">
@@ -6,151 +6,177 @@
</p> </p>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) [![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons)
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. **AE Anons** — полностью автономная (standalone) система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender. Все данные берутся из электронных таблиц Synology Office, рендеринг выполняется локально, управление заданиями происходит через встроенный веб-интерфейс с мгновенными обновлениями. Система не требует доступа в интернет — все ресурсы (иконки, стили, шрифты) встроены в бинарный файл.
## Новое в версии v0.3.0
- **WebSocket** — мгновенные обновления списка заданий (без устаревшего polling каждые 60 секунд)
- **SQLite** — хранение истории заданий и статуса апрува
- **Просмотр видео** прямо в веб-интерфейсе (по ссылке на готовый файл)
- **Апрув заданий** с копированием видео на SMB-шару (без монтирования, с поддержкой Active Directory)
- **Уведомления в реальном времени** о старте/завершении генерации, ошибках
- **Улучшенный веб-интерфейс** с кнопками апрува и иконками превью
- **Оптимизация памяти** — строки таблицы обрабатываются потоково, неиспользуемые логотипы и ассеты удаляются после генерации. Пиковое потребление памяти достигается только в момент загрузки и парсинга Excel, после чего память освобождается. Это позволяет работать с таблицами большого объёма (десятки тысяч строк) и обеспечивает стабильную работу в режиме долгоживущего сервиса.
- Полная автономность — все статические ресурсы (HTML, CSS, Font Awesome, логотип, favicon) встроены в бинарный файл.
## Обзор ## Обзор
AE Anons — это CLI-утилита на Rust, которая выступает как **интеллектуальный генератор заданий** AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий** для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга After Effects.
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
After Effects (лицензия MIT).
1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet) ### Режимы работы
2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах
3. Генерации заданий Nexrender с соответствующими шаблонами и ресурсами 1. **Однократная обработка** (`--once` или без флагов):
4. Мониторинга завершения рендеринга и управления выходными файлами - Подключение к NAS Synology
- Парсинг Excel данных
- Генерация и отправка заданий Nexrender
- Мониторинг завершения и выход
2. **Веб-сервер** (`--web`):
- Запуск веб-интерфейса на порту `:3000` (настраивается)
- Управление заданиями через браузер с WebSocket-обновлениями
- Просмотр видео и апрув готовых роликов
## Особенности ## Особенности
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology - **Полная автономность** — не требует выхода в интернет, все ресурсы встроены в бинарник
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel - **Интеграция с Synology** — аутентификация и загрузка файлов с NAS Synology (локальные и AD-учётные записи)
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков - **Экспорт офисных таблиц** — автоматическое преобразование `.osheet` в Excel
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса - **Гибкий парсинг данных** — динамический парсинг листов с обнаружением заголовков
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды - **Множественная генерация вариантов** — "Сегодня", "Завтра" и датированные версии
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий - **Умное управление логотипами** — автоматическое разрешение и масштабирование
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации - **Веб-интерфейс с WebSocket** — мгновенные обновления, просмотр видео, апрув
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env` - **REST API** — программное управление заданиями
- **SQLite** — хранение истории и статуса апрува
- **Поддержка SMB** — копирование утверждённых видео на сетевую шару (с AD-аутентификацией)
- **Профессиональное логирование** — структурированный журнал с настройкой уровня
- **Эффективная работа с памятью** — потоковая обработка строк, своевременное удаление неиспользуемых данных (логотипы, ассеты) позволяет обрабатывать таблицы с десятками тысяч строк без утечек памяти
## Предварительные требования ## Предварительные требования
- Rust 1.70 или выше - Rust 1.70 или выше
- Доступ к NAS Synology с установленными и включенными пакетами File Station и Office - Доступ к NAS Synology с пакетами File Station и Office (локальный или доменный пользователь)
- Экземпляр _server_ и _worker_ (не менее одного) Nexrender - Экземпляр _server_ и _worker_ (не менее одного) Nexrender
- Шаблоны After Effects, настроенные на _worker_ (формат Adobe After Effects 2024 .aepx) - Шаблоны After Effects (формат .aep или .aepx, совместимые с версией AE на worker)
- (Для апрува) Доступ к SMB-шаре с правами на запись
## Установка ## Установка
### 1. Клонирование репозитория git clone https://git.example.com/team/ae_anons.git
```bash
git clone https://git.tvstart.ru/lexx/AE_Anons.git
cd ae_anons cd ae_anons
```
### 2. Сборка проекта
```bash
cargo build --release cargo build --release
```
Бинарный файл будет доступен по пути `target/release/ae_anons`
### 3. Настройка окружения
Скопируйте пример конфигурации и заполните своими данными:
```bash
cp .env.example .env cp .env.example .env
``` # отредактируйте .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
# Запуск с пользовательским уровнем логирования
RUST_LOG=debug ./target/release/ae_anons
```
## Уровни логирования
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
- **error** - Только критические ошибки
- **warn** - Предупреждения и ошибки
- **info** - Общие операционные сообщения (по умолчанию)
- **debug** - Детальная информация о процессе обработки
- **trace** - Полное отладочное логирование с деталями API вызовов
```bash
RUST_LOG=debug cargo run
```
## Конфигурация ## Конфигурация
### Переменные окружения Все настройки задаются через переменные окружения в файле `.env`.
### Основные переменные
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
|-------------------------|-------------|------------------------------------------------------| |-------------------------|-------------|---------------------------------------------------------------------------------|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом | | `NAS_FQDN` | Да | URL Synology NAS (*) |
| `NAS_USER` | Да | Имя пользователя учетной записи Synology | | `NAS_USER` | Да | Имя пользователя (локального или доменного, например `DOMAIN\username`) |
| `NAS_PASS` | Да | Пароль учетной записи Synology | | `NAS_PASS` | Да | Пароль |
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS | | `NAS_FILE` | Да | Путь к `.osheet` файлу на NAS (например, `/Team Folder/schedule.osheet`) |
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender | | `NEXRENDER_API_URL` | Да | API Nexrender (например, `http://nexrender:3050/api/v1/jobs`) |
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео | | `OUTPUT_FOLDER` | Да | Папка, куда Nexrender сохраняет готовые видео (локальный путь) |
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) | | `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд (например, `file:///templates/double.aepx`) |
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды | | `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) | | `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE |
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) | | `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE |
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) | | `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
| `WEB_PORT` | Нет | Порт веб-сервера (по умолчанию 3000) |
| `RUST_LOG` | Нет | Уровень логирования (info, debug, trace) |
| `SMB_UPLOAD_URL` | Да(**) | SMB-URL для апрува (например, `smb://storage/approved`) |
| `SMB_USERNAME` | Да(**) | Имя пользователя для SMB (локальный или `DOMAIN\user`) |
| `SMB_PASSWORD` | Да(**) | Пароль для SMB |
| `DATABASE_URL` | Нет | Путь к SQLite БД (по умолчанию `sqlite:ae_anons.db`) |
> (*) — для `NAS_FQDN` можно указывать протокол `http://` или `https://`. Безопаснее `https`, хотя для локальной сети допустим и `http`. Если NAS доступен по стандартному HTTPS-порту 443, протокол можно опустить (например, `nas.company.local`). Иначе указывайте полный URL с портом: `https://nas.company.local:5001` или `http://nas.local:5000`.
>
> (**) — обязательно, если используется функция апрува (веб-режим). В режиме `--once` апрув не требуется.
### Пример файла `.env` ### Пример файла `.env`
```env
# Synology NAS # Synology NAS
NAS_FQDN="https://your-nas.example.com" NAS_FQDN="https://nas.company.local:5001"
NAS_USER="your_username" NAS_USER="DOMAIN\\service_account"
NAS_PASS="your_password" NAS_PASS="strongpassword"
NAS_FILE="/Team Folder/path/to/file.osheet" NAS_FILE="/Team Folder/Anonsy/sport.osheet"
#Loging
RUST_LOG="info"
# Nexrender # Nexrender
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
OUTPUT_FOLDER="/path/to/output" OUTPUT_FOLDER="/mnt/nexrender/output"
# After Effects Templates # After Effects Templates
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx" TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx" TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
TEMPLATE_COMPOSITION="main" TEMPLATE_COMPOSITION="main"
TEMPLATE_OUTPUT_MODULE="h264" TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4" TEMPLATE_OUTPUT_EXT="mp4"
```
# Web server
WEB_PORT="3000"
RUST_LOG="info"
# SMB для апрува (AD-совместимо)
SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
SMB_USERNAME="DOMAIN\\service_account"
SMB_PASSWORD="strongpassword"
# Database
DATABASE_URL="sqlite:/var/lib/ae_anons/ae_anons.db"
> **Примечание об аутентификации:**
> Synology API и SMB-библиотека `smb` поддерживают как локальных пользователей NAS, так и доменных (Active Directory). Для доменных пользователей используйте формат `DOMAIN\username` (обратный слеш необходимо экранировать в `.env` как `\\`). Проверено на Synology DSM 7.x и Samba AD.
## Использование
### Однократная обработка (cron или ручной запуск)
./target/release/ae_anons --once
### Веб-сервер (интерактивный режим)
./target/release/ae_anons --web
После запуска откройте браузер: `http://localhost:3000`
## Веб-интерфейс
- **Главная страница** — таблица всех заданий с полями: UID, имя файла, статус, превью (иконка видео), кнопка апрува, дата создания.
- **Обновления в реальном времени** через WebSocket — статусы заданий меняются мгновенно без перезагрузки страницы.
- **Кнопка «Generate»** — запускает парсинг таблицы Synology и создание новых заданий. Во время генерации кнопка блокируется, при завершении приходит уведомление.
- **Кнопка «Cleanup»** — удаляет из Nexrender задания со статусом finished/error.
- **Кнопка «Stop all»** — останавливает все активные (queued/started/processing) задания.
- **Просмотр видео** — клик по иконке видео открывает готовый файл в новой вкладке (поддерживается любой браузерный просмотр MP4).
- **Апрув** — клик по ✅ копирует видео на SMB-шару (путь из `SMB_UPLOAD_URL`) и помечает задание как approved. Повторный апрув невозможен.
### API эндпоинты
- `GET /api/jobs` — список всех заданий (из SQLite)
- `POST /api/generate` — запуск генерации
- `POST /api/cleanup` — очистка завершённых
- `POST /api/jobs/stop-all` — остановка активных
- `GET /api/status` — статус сервера
- `GET /api/video/:uid` — просмотр видеофайла
- `POST /api/approve/:uid` — апрув (копирование на SMB)
- `WS /ws` — WebSocket для получения событий (`JobUpdated`, `GenerationStarted`, `GenerationFinished`, `Error`)
## Уровни логирования
`RUST_LOG=debug ./ae_anons --web`
- **error** — только критические ошибки
- **warn** — предупреждения
- **info** — стандартная информация (по умолчанию)
- **debug** — детали API-вызовов, парсинга, SMB-операций
- **trace** — максимальная детализация
## Структура электронной таблицы ## Структура электронной таблицы
@@ -183,7 +209,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков. Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
| CHANEL | LINK | | CHANELL | LINK |
|---------|-----------------------------------------| |---------|-----------------------------------------|
| КАНАЛ | \\server\share\path\to\channel_logo.png | | КАНАЛ | \\server\share\path\to\channel_logo.png |
| TRIUMPH | \\server\share\path\to\triumph_logo.png | | TRIUMPH | \\server\share\path\to\triumph_logo.png |
@@ -192,12 +218,15 @@ 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 | - Колонка 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 | | TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki| | FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
@@ -206,12 +235,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
Имена команд поддерживают два типа разделителей хэш #: Имена команд поддерживают два типа разделителей хэш #:
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта. 1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
Примеры: 2. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
Формат: `DisplayName#UniqueID#TargetSize` Формат: `DisplayName#UniqueID#TargetSize`
@@ -221,206 +245,70 @@ TEMPLATE_OUTPUT_EXT="mp4"
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш) - `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
- `Спортинг#ll#550` - оба идентификатор и целевой размер - `Спортинг#ll#550` - оба идентификатор и целевой размер
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
## Выходные файлы ## Выходные файлы
### Рендеренные видео Рендеренные видео сохраняются в `OUTPUT_FOLDER` по шаблону:
`YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4`
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования: При апруве файл копируется в SMB-шару с тем же именем.
```shell ## Рабочий процесс (веб-режим)
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
```
Имена файлов транслитерируются в латиницу. Примеры: 1. Пользователь заполняет таблицу Synology Office.
2. Нажимает «Generate» в веб-интерфейсе.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4` 3. Сервер аутентифицируется на NAS, скачивает и парсит Excel.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4` 4. Генерирует задания Nexrender, сохраняет их в SQLite и отправляет в Nexrender.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4` 5. WebSocket уведомляет клиент о новых заданиях.
6. Nexrender рендерит видео, статус задания обновляется (периодический опрос или webhook).
## Рабочий процесс 7. Когда видео готово, пользователь видит иконку превью, может посмотреть видео и нажать апрув.
8. При апруве видео копируется на SMB-шару, задание помечается как approved.
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
3. **Парсинг данных**: Чтение всех листов и структурирование данных
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
7. **Отправка**: Отправка заданий на API Nexrender
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
9. **Завершение сеанса**: Закрытие сессии Synology
## Требования к шаблонам After Effects ## Требования к шаблонам After Effects
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоев: Не изменились — все слои `DATA`, `TIME_H`, `TIME_M`, `LEAGUE`, `SPORT`, `TEAMS`, `TEAM_A_LOGO`, `TEAM_B_LOGO`, `CHANELL`, `TOP` должны присутствовать согласно типу шаблона (DOUBLE/SINGLE). Поддержка масштабирования логотипов через выражения After Effects — осталась.
### Типы шаблонов
| Файл шаблона | Назначение |
|------------------------|---------------------------------|
| `PackShot_DOUBLE.aepx` | Сопоставление с двумя командами |
| `PackShot_SINGLE.aepx` | Анонсы для одной команды |
### Обязательные слои
| Имя слоя | Тип | Описание |
|------------------------|-------|-------------------------------------------------|
| 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 | Наложение видео оформления |
### Настройки композиции
- Имя композиции: `pack`
- Выходной модуль: `Start_h264`
- Формат вывода: `mp4`
- Формат проекта: Adobe After Effects 2024 .aepx
## Устранение неисправностей ## Устранение неисправностей
### Частые проблемы ### WebSocket не работает
#### Ошибка подключения к NAS - Проверьте, что браузер поддерживает WebSocket (все современные поддерживают).
- При использовании прокси (nginx) необходимо настроить Upgrade заголовки.
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`) ### Ошибка аутентификации на NAS для доменного пользователя
- Проверьте сетевую связность с NAS
- Убедитесь, что сервисы File Station и Office включены
#### Файл не найден - Убедитесь, что в `.env` указано `DOMAIN\\username` (двойной обратный слеш).
- Проверьте, что NAS настроен на приём доменных учётных записей (DSM → Домен/LDAP).
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive ### Ошибка SMB подключения
- Путь должен начинаться с `/Team Folder/` для рабочих папок
- Проверьте права доступа к файлу для учетной записи сервиса
#### Ошибка отправки задания Nexrender - Проверьте доступность шары: `smbclient -U DOMAIN/username -L //storage/`
- Убедитесь, что в URL используется `smb://` протокол, путь без лишних слешей.
- Время на сервере и клиенте должно быть синхронизировано (SMB требует этого).
- Подтвердите доступность сервера Nexrender ### БД SQLite блокирована
- Убедитесь, что `NEXRENDER_API_URL` правильный
- Проверьте существование файлов шаблонов на узлах рендеринга
### Режим отладки - При конкурентных запросах SQLite может выдавать `database is locked`. Для веб-сервера с одним процессом это маловероятно. Если возникает — используйте `sqlite://:memory:` для тестов или настройте WAL-режим.
Включите подробное логирование для детального анализа: ### Высокое потребление памяти
```bash - Система спроектирована так, чтобы минимизировать удержание данных. Если вы наблюдаете рост памяти, убедитесь, что вы используете последнюю версию. При работе с очень большими таблицами (сотни тысяч строк) может потребоваться увеличить лимит строк в `processor.rs` (константа 10000).
RUST_LOG=debug ./target/release/ae_anons
```
Это выведет:
- Детали API запросов/ответов
- Информацию о парсинге листов
- Детали создания заданий
- Процесс разрешения ресурсов
## Возможные ограничения по производительности
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
- **Использование памяти**: Парсинг Excel сохраняет всю книгу в памяти
## Примечания по безопасности
- Храните учетные данные только в файле `.env` (исключен из git)
- Используйте специальные аккаунты с минимально необходимыми правами
- Сессии Synology завершаются после выполнения
- HTTPS рекомендуется для подключений NAS в продакшене
## Разработка ## Разработка
### Структура кода Все статические ресурсы (HTML, CSS, шрифты Font Awesome, логотип, favicon) встроены в бинарник с помощью `include_str!` и `include_bytes!`. Для разработки можно редактировать файлы в `src/static/`, но при сборке они компилируются внутрь исполняемого файла.
```shell ### Сборка для production
ae_anons/
├── Cargo.toml
├── LICENSE
├── assets/
│ └── logo.png
├── README.md
├── .env.example
└── src/
├── main.rs # Точка входа и оркестрация приложения
├── config.rs # Управление конфигурацией
├── nexrender.rs # Генерация заданий Nexrender и структура данных
└── synology.rs # Клиент API Synology
```
## Зависимости cargo build --release
strip target/release/ae_anons # уменьшает размер
| Crate | Версия | Назначение | Итоговый бинарник можно переносить на любой Linux-сервер без дополнительных зависимостей (кроме libc и openssl, если не используется статическая сборка).
|--------------------|--------|-----------------------------------------|
| 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 | Обработка ошибок |
### Планируемые новые возможности
1. Расширить `JobData` в `nexrender.rs` для новых полей данных
2. Обновить логику парсинга листов при необходимости добавления новых колонок
3. Добавить соответствующие слои After Effects в шаблоны
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
## 🙏 Благодарности
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
## Лицензия ## Лицензия
**AE Anons**[MIT License](LICENSE) **AE Anons** — MIT
**Nexrender**[MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE) **Nexrender** — MIT
**Font Awesome Free** — CC BY 4.0 (иконки) и SIL OFL 1.1 (шрифты)
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
**Разрешается:**
- ✅ Использовать в коммерческих целях
- ✅ Изменять исходный код
- ✅ Распространять копии
- ✅ Использовать приватно
**Требуется:**
- Сохранять копирайт и текст лицензии
## Поддержка
Для вопросов и запросов функций обращайтесь:
- <a.barabanov@tvstart.ru>
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
## История изменений
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
- Создание множественных вариантов
- Автоматические настройки размера шрифта и позиции
- Умное масштабирование логотипов по целевому размеру
### v0.1.1
- Оптимизированна работат с памятью
- Убрана функция создания `.json`
--- ---

BIN
assets/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

9
build.rs Normal file
View File

@@ -0,0 +1,9 @@
// build.rs
fn main() {
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/logo.ico");
res.compile().unwrap();
}
}

297
makefile Normal file
View File

@@ -0,0 +1,297 @@
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single icons favicon
# Название проекта
PROJECT_NAME := ae_anons
RELEASE_DIR := target/release
BINARIES_DIR := releases
VERSION := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
# Определение текущей ОС
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
# Цвета для вывода
GREEN := \033[0;32m
RED := \033[0;31m
YELLOW := \033[1;33m
BLUE := \033[0;34m
CYAN := \033[0;36m
MAGENTA := \033[0;35m
BOLD := \033[1m
NC := \033[0m
# Файл для хранения времени сборки
TIMING_FILE := .build_timing
# Определение команды ImageMagick (v6 = convert, v7 = magick)
IMAGEMAGICK := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev/null || echo "false")
help:
@echo "$(BOLD)$(CYAN)AE Anons - Makefile команды$(NC)"
@echo ""
@echo "$(GREEN)Сборка под конкретную платформу:$(NC)"
@echo " make build-mac - Собрать для macOS (Apple Silicon)"
@echo " make build-windows - Собрать для Windows x86_64"
@echo " make build-linux - Собрать для Linux x86_64 (GNU)"
@echo " make build-linux-musl - Собрать для Linux x86_64 (musl static)"
@echo ""
@echo "$(GREEN)Быстрая сборка:$(NC)"
@echo " make quick - Собрать под текущую платформу с упаковкой"
@echo " make quick-release - Собрать под текущую платформу (release, без упаковки)"
@echo ""
@echo "$(GREEN)Полная сборка:$(NC)"
@echo " make build-all - Собрать для всех платформ"
@echo " make package - Создать пакеты для всех платформ"
@echo " make package-single - Создать пакет для текущей платформы"
@echo ""
@echo "$(GREEN)Иконки:$(NC)"
@echo " make icons - Создать все иконки (logo.ico, logo.icns, favicon.ico)"
@echo " make favicon - Создать только favicon.ico"
@echo ""
@echo "$(GREEN)Очистка:$(NC)"
@echo " make clean - Очистить все сборки"
@echo " make clean-timing - Очистить файл с временами сборки"
# ============================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================
define copy_common_files
@cp .env.example $(1)/ 2>/dev/null || true
@cp README.md $(1)/ 2>/dev/null || true
@if [ -f LICENSE ]; then cp LICENSE $(1)/; else echo "$(YELLOW)⚠️ LICENSE не найден, пропускаем$(NC)"; fi
endef
define measure_time
@start=$$(date +%s); \
$(2); \
end=$$(date +%s); \
duration=$$((end - start)); \
if [ $$duration -ge 60 ]; then \
min=$$((duration / 60)); \
sec=$$((duration % 60)); \
time_str="$${min}m $${sec}s"; \
else \
time_str="$${duration}s"; \
fi; \
echo "$(1): $$time_str" >> $(TIMING_FILE); \
echo "$(GREEN)$(1) завершён за $$time_str$(NC)"
endef
define print_timing_summary
@echo ""
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
@echo "$(BOLD)$(CYAN) ВРЕМЯ СБОРКИ $(NC)"
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
@if [ -f $(TIMING_FILE) ]; then \
total=0; \
while IFS=':' read -r platform duration; do \
sec=$$(echo $$duration | sed 's/ сек//'); \
total=$$((total + sec)); \
printf "$(YELLOW)%-25s$(NC) : $(GREEN)%5d сек$(NC)\n" "$$platform" "$$sec"; \
done < $(TIMING_FILE); \
echo "$(BOLD)$(CYAN)───────────────────────────────────────────────────────────────$(NC)"; \
printf "$(BOLD)$(MAGENTA)%-25s$(NC) : $(BOLD)$(GREEN)%5d сек$(NC) (%.2f мин)\n" "ВСЕГО" "$$total" "$$(echo "scale=2; $$total/60" | bc 2>/dev/null || echo "$$total/60" | awk '{printf "%.2f", $$1/60}')"; \
else \
echo "$(YELLOW)Нет данных о времени сборки$(NC)"; \
fi
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
@rm -f $(TIMING_FILE)
endef
# ============================================
# ИКОНКИ
# ============================================
icons: favicon
@echo "$(GREEN)Создание иконок для Windows и macOS...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \
fi
# Windows .ico
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=256,128,64,48,32,16 assets/logo.ico
@echo "$(GREEN) ✓ assets/logo.ico$(NC)"
# macOS .icns
@mkdir -p assets/icon.iconset
@sips -z 16 16 assets/logo.png --out assets/icon.iconset/icon_16x16.png 2>/dev/null
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_16x16@2x.png 2>/dev/null
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_32x32.png 2>/dev/null
@sips -z 64 64 assets/logo.png --out assets/icon.iconset/icon_32x32@2x.png 2>/dev/null
@sips -z 128 128 assets/logo.png --out assets/icon.iconset/icon_128x128.png 2>/dev/null
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_128x128@2x.png 2>/dev/null
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_256x256.png 2>/dev/null
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_256x256@2x.png 2>/dev/null
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_512x512.png 2>/dev/null
@iconutil -c icns assets/icon.iconset -o assets/logo.icns 2>/dev/null
@rm -rf assets/icon.iconset
@echo "$(GREEN) ✓ assets/logo.icns$(NC)"
favicon:
@echo "$(GREEN)Создание favicon.ico...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \
fi
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=48,32,16 src/static/favicon.ico
@echo "$(GREEN) ✓ src/static/favicon.ico$(NC)"
# ============================================
# ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ
# ============================================
detect-platform:
ifeq ($(UNAME_S),Darwin)
@echo "$(BLUE)🍎 macOS $(UNAME_M)$(NC)"
@$(MAKE) quick-mac
else ifeq ($(UNAME_S),Linux)
@echo "$(BLUE)🐧 Linux $(UNAME_M)$(NC)"
@$(MAKE) quick-linux
else ifeq ($(OS),Windows_NT)
@echo "$(BLUE)🪟 Windows$(NC)"
@$(MAKE) quick-windows
else
@echo "$(RED)❌ Неизвестная платформа: $(UNAME_S)$(NC)"
@exit 1
endif
# ============================================
# БЫСТРАЯ СБОРКА (ТЕКУЩАЯ ПЛАТФОРМА)
# ============================================
quick: detect-platform
quick-release:
ifeq ($(UNAME_S),Darwin)
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@time cargo build --release
else ifeq ($(UNAME_S),Linux)
@echo "$(GREEN)🐧 Сборка для Linux (musl)...$(NC)"
@time cargo build --target x86_64-unknown-linux-musl --release
else ifeq ($(OS),Windows_NT)
@echo "$(GREEN)🪟 Сборка для Windows...$(NC)"
@time cargo build --release
else
@echo "$(RED)❌ Неизвестная платформа$(NC)"
@exit 1
endif
quick-mac:
@rm -f $(TIMING_FILE)
@$(call measure_time,"🍎 macOS $(UNAME_M)",cargo build --release)
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)
@cp $(RELEASE_DIR)/$(PROJECT_NAME) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)/
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M))
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)/$(NC)"
@$(call print_timing_summary)
quick-linux:
@rm -f $(TIMING_FILE)
@$(call measure_time,"🐧 Linux $(UNAME_M)",cargo build --target x86_64-unknown-linux-musl --release)
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)
@cp target/x86_64-unknown-linux-musl/release/$(PROJECT_NAME) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M))
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)"
@$(call print_timing_summary)
quick-windows:
@rm -f $(TIMING_FILE)
@if command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then \
$(call measure_time,"🪟 Windows x64",cargo build --target x86_64-pc-windows-gnu --release); \
mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64; \
cp target/x86_64-pc-windows-gnu/release/$(PROJECT_NAME).exe $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64/; \
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64); \
echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64/$(NC)"; \
$(call print_timing_summary); \
else \
echo "$(RED)❌ Для кросс-компиляции Windows установите mingw-w64:$(NC)"; \
echo " brew install mingw-w64"; \
exit 1; \
fi
# ============================================
# СБОРКА ПОД КОНКРЕТНЫЕ ПЛАТФОРМЫ
# ============================================
build-mac:
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@time cargo build --release
build-windows:
@echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)"
@time cargo build --target x86_64-pc-windows-gnu --release
build-linux:
@echo "$(GREEN)🐧 Сборка для Linux x86_64 (GNU)...$(NC)"
@time cargo build --target x86_64-unknown-linux-gnu --release
build-linux-musl:
@echo "$(GREEN)🐧 Сборка для Linux x86_64 (musl static)...$(NC)"
@time cargo build --target x86_64-unknown-linux-musl --release
# ============================================
# СБОРКА ВСЕХ ПЛАТФОРМ С ТАЙМИНГОМ
# ============================================
build-all:
@rm -f $(TIMING_FILE)
@echo "$(BOLD)$(CYAN)🚀 Сборка для всех платформ...$(NC)"
@echo ""
@$(call measure_time,"🍎 macOS",cargo build --release)
@echo ""
@$(call measure_time,"🐧 Linux GNU",cargo build --target x86_64-unknown-linux-gnu --release)
@echo ""
@$(call measure_time,"🐧 Linux Musl",cargo build --target x86_64-unknown-linux-musl --release)
@echo ""
@-$(call measure_time,"🪟 Windows",cargo build --target x86_64-pc-windows-gnu --release)
@echo ""
@$(call print_timing_summary)
# ============================================
# УПАКОВКА РЕЛИЗОВ
# ============================================
define create_release_package
@echo "$(YELLOW)📦 Упаковка для $(1)...$(NC)"
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1)
@cp $(2) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1)/$(3) 2>/dev/null || true
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1))
@echo "$(GREEN) ✓ $(PROJECT_NAME)-v$(VERSION)-$(1)$(NC)"
endef
package: build-all
@echo ""
@echo "$(BOLD)$(CYAN)📦 Создание пакетов...$(NC)"
@mkdir -p $(BINARIES_DIR)
@$(call create_release_package,macos-arm64,$(RELEASE_DIR)/$(PROJECT_NAME),$(PROJECT_NAME))
@$(call create_release_package,windows-x64,target/x86_64-pc-windows-gnu/release/$(PROJECT_NAME).exe,$(PROJECT_NAME).exe)
@$(call create_release_package,linux-gnu,target/x86_64-unknown-linux-gnu/release/$(PROJECT_NAME),$(PROJECT_NAME))
@$(call create_release_package,linux-musl,target/x86_64-unknown-linux-musl/release/$(PROJECT_NAME),$(PROJECT_NAME))
@echo ""
@echo "$(GREEN)🗜️ Создание общего архива...$(NC)"
@tar -czf $(PROJECT_NAME)-v$(VERSION)-all.tar.gz -C $(BINARIES_DIR) .
@echo "$(GREEN)✅ Архив: $(PROJECT_NAME)-v$(VERSION)-all.tar.gz$(NC)"
@echo ""
@echo "$(BOLD)$(GREEN)🎉 Релиз v$(VERSION) готов!$(NC)"
@echo "$(BOLD)📁 Папка: $(BINARIES_DIR)/$(NC)"
@ls -lh $(BINARIES_DIR)/ | grep "^d"
package-single: detect-platform
# ============================================
# ОЧИСТКА
# ============================================
clean:
@echo "$(RED)🧹 Очистка...$(NC)"
cargo clean
@rm -rf $(BINARIES_DIR)
@rm -f *.tar.gz
@rm -f $(TIMING_FILE)
@echo "$(GREEN)✅ Очищено$(NC)"
clean-timing:
@rm -f $(TIMING_FILE)
@echo "$(GREEN)✅ Файл с временами сборки удалён$(NC)"

View File

@@ -1,5 +1,4 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use dotenv::dotenv;
use std::env; use std::env;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -12,17 +11,29 @@ pub struct Config {
// Nexrender // Nexrender
pub nexrender_api_url: String, pub nexrender_api_url: String,
pub output_folder: String, pub output_folder: String,
// Templates (all required) // Templates
pub template_double_src: String, pub template_double_src: String,
pub template_single_src: String, pub template_single_src: String,
pub template_composition: String, pub template_composition: String,
pub template_output_module: String, pub template_output_module: String,
pub template_output_ext: String, pub template_output_ext: String,
// Web
pub web_port: u16,
// SMB for approved videos (optional)
pub smb_upload_url: Option<String>,
pub smb_username: Option<String>,
pub smb_password: Option<String>,
// SMB source folder (where videos are stored locally before upload)
pub smb_source_folder: String,
// Database
pub database_url: String,
} }
impl Config { impl Config {
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
dotenv().ok(); let output_folder = env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?;
let smb_source_folder =
env::var("SMB_SOURCE_FOLDER").unwrap_or_else(|_| output_folder.clone());
Ok(Self { Ok(Self {
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?, nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
@@ -31,7 +42,7 @@ impl Config {
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?, nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
nexrender_api_url: env::var("NEXRENDER_API_URL") nexrender_api_url: env::var("NEXRENDER_API_URL")
.context("NEXRENDER_API_URL not set")?, .context("NEXRENDER_API_URL not set")?,
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?, output_folder,
template_double_src: env::var("TEMPLATE_DOUBLE_SRC") template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
.context("TEMPLATE_DOUBLE_SRC not set")?, .context("TEMPLATE_DOUBLE_SRC not set")?,
template_single_src: env::var("TEMPLATE_SINGLE_SRC") template_single_src: env::var("TEMPLATE_SINGLE_SRC")
@@ -42,6 +53,16 @@ impl Config {
.context("TEMPLATE_OUTPUT_MODULE not set")?, .context("TEMPLATE_OUTPUT_MODULE not set")?,
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT") template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
.context("TEMPLATE_OUTPUT_EXT not set")?, .context("TEMPLATE_OUTPUT_EXT not set")?,
web_port: env::var("WEB_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("Invalid WEB_PORT")?,
smb_upload_url: env::var("SMB_UPLOAD_URL").ok(),
smb_username: env::var("SMB_USERNAME").ok(),
smb_password: env::var("SMB_PASSWORD").ok(),
smb_source_folder,
database_url: env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:ae_anons.db".to_string()),
}) })
} }
} }

View File

@@ -1,480 +1,44 @@
mod config; mod config;
mod nexrender; mod nexrender;
mod processor;
mod storage;
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 dotenv::dotenv;
use nexrender::{JobData, LogoRegistry}; use log::info;
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, #[arg(short, long)]
pub headers: Vec<String>, web: bool,
pub rows: Vec<HashMap<String, String>>, #[arg(short, long)]
} once: bool,
#[derive(Debug, Default)]
pub struct ExcelWorkbook {
pub sheets: Vec<SheetData>,
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
dotenv().ok();
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); if cli.web {
client.login(&config.nas_user, &config.nas_pass).await?; info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION"));
info!("Successfully authenticated with Synology NAS"); web::run_web_server(config).await?;
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 { } else {
h info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION"));
} // For one-time mode we don't need SMB, so we create dummy db and channel
}) let (dummy_ws_tx, _) = tokio::sync::broadcast::channel(1);
.collect(); let dummy_db = sqlx::SqlitePool::connect("sqlite::memory:").await?;
let submitted = processor::process_spreadsheet(&config, &dummy_db, dummy_ws_tx).await?;
// Парсим строки данных info!("Submitted {} jobs. Exiting.", submitted.len());
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);
}
}
}
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?;
} }
Ok(()) 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 {
remaining.push((uid, outname));
}
}
pending = remaining;
if !pending.is_empty() {
info!("Waiting for {} jobs to complete...", pending.len());
}
}
info!("All jobs completed successfully");
Ok(())
}

View File

@@ -1,5 +1,6 @@
#![allow(dead_code)] #![allow(dead_code)]
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use regex::Regex;
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
@@ -154,6 +155,8 @@ pub enum Asset {
asset_type: String, asset_type: String,
#[serde(rename = "layerName")] #[serde(rename = "layerName")]
layer_name: String, layer_name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
cache: bool,
}, },
Video { Video {
src: String, src: String,
@@ -161,6 +164,8 @@ pub enum Asset {
asset_type: String, asset_type: String,
#[serde(rename = "layerName")] #[serde(rename = "layerName")]
layer_name: String, layer_name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
cache: bool,
}, },
} }
@@ -207,6 +212,10 @@ pub struct JobData {
pub channel_logo: String, pub channel_logo: String,
pub outfile_name: String, pub outfile_name: String,
pub row_index: usize, pub row_index: usize,
// Поля для сортировки
pub sort_date: NaiveDate,
pub sort_time: String,
pub variant_order: u8,
} }
impl JobData { impl JobData {
@@ -232,6 +241,7 @@ impl JobData {
let (date_obj, data_display) = parse_date(&data_str); let (date_obj, data_display) = parse_date(&data_str);
let (time_h, time_m) = parse_time(&time); let (time_h, time_m) = parse_time(&time);
let sort_time = format!("{}:{}", time_h, time_m);
let pack_path = packs.get(&sport).cloned().unwrap_or_default(); let pack_path = packs.get(&sport).cloned().unwrap_or_default();
let channel_logo = channels.get(&channel).cloned().unwrap_or_default(); let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
@@ -263,10 +273,17 @@ impl JobData {
fn_parts.push(channel.clone()); fn_parts.push(channel.clone());
} }
let outfile_name = transliterate(&fn_parts.join("_"), true) let mut outfile_name = transliterate(&fn_parts.join("_"), true)
.replace(' ', "-") .replace(' ', "-")
.replace('\'', ""); .replace('\'', "");
// Заменяем 2 и более дефисов подряд на один
let re = Regex::new(r"-{2,}").unwrap();
outfile_name = re.replace_all(&outfile_name, "-").to_string();
// Убираем дефисы в начале и конце
outfile_name = outfile_name.trim_matches('-').to_string();
Some(Self { Some(Self {
date: data_str, date: data_str,
time: time.clone(), time: time.clone(),
@@ -286,23 +303,37 @@ impl JobData {
channel_logo: unc_to_uri(&channel_logo), channel_logo: unc_to_uri(&channel_logo),
outfile_name, outfile_name,
row_index, row_index,
sort_date: date_obj,
sort_time,
variant_order: 0,
}) })
} }
pub fn create_variants(&self) -> Vec<Self> { pub fn create_variants(&self) -> Vec<Self> {
let mut variants = vec![self.clone()]; let mut variants = Vec::new();
// Оригинал (0)
let mut original = self.clone();
original.variant_order = 0;
variants.push(original);
// Today (1)
let mut today = self.clone(); let mut today = self.clone();
today.data_display = "сегодня".to_string(); today.data_display = "сегодня".to_string();
today.outfile_name = format!("{}_Today", self.outfile_name); today.outfile_name = format!("{}_Today", self.outfile_name);
today.variant_order = 1;
variants.push(today); variants.push(today);
// Tomorrow (2)
let mut tomorrow = self.clone(); let mut tomorrow = self.clone();
tomorrow.data_display = "завтра".to_string(); tomorrow.data_display = "завтра".to_string();
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name); tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
tomorrow.variant_order = 2;
variants.push(tomorrow); variants.push(tomorrow);
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(
@@ -339,6 +370,7 @@ impl JobData {
src: self.pack_path.clone(), src: self.pack_path.clone(),
asset_type: "video".to_string(), asset_type: "video".to_string(),
layer_name: "TOP".to_string(), layer_name: "TOP".to_string(),
cache: true,
}); });
} }
@@ -350,6 +382,7 @@ impl JobData {
} }
fn add_text_assets(&self, assets: &mut Vec<Asset>) { fn add_text_assets(&self, assets: &mut Vec<Asset>) {
// DATA — всегда
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "DATA".to_string(), layer_name: "DATA".to_string(),
@@ -358,7 +391,9 @@ impl JobData {
}); });
self.add_date_adjustments(assets); self.add_date_adjustments(assets);
// TIME — зависит от шаблона
if !self.team_b.is_empty() { if !self.team_b.is_empty() {
// DOUBLE: TIME_H и TIME_M
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TIME_H".to_string(), layer_name: "TIME_H".to_string(),
@@ -373,6 +408,7 @@ impl JobData {
}); });
self.add_time_adjustments(assets); self.add_time_adjustments(assets);
} else { } else {
// SINGLE: TIME
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TIME".to_string(), layer_name: "TIME".to_string(),
@@ -381,6 +417,7 @@ impl JobData {
}); });
} }
// LEAGUE — всегда
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "LEAGUE".to_string(), layer_name: "LEAGUE".to_string(),
@@ -389,6 +426,7 @@ impl JobData {
}); });
self.add_league_adjustments(assets); self.add_league_adjustments(assets);
// SPORT — всегда
if !self.sport.is_empty() { if !self.sport.is_empty() {
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
@@ -398,14 +436,9 @@ impl JobData {
}); });
} }
let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() { // TEAMS — ТОЛЬКО для DOUBLE шаблона
format!("{} - {}", self.team_a, self.team_b) if !self.team_b.is_empty() {
} else if !self.team_a.is_empty() { let teams_text = format!("{} - {}", self.team_a, self.team_b);
self.team_a.clone()
} else {
self.team_b.clone()
};
if !teams_text.is_empty() {
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TEAMS".to_string(), layer_name: "TEAMS".to_string(),
@@ -478,11 +511,13 @@ impl JobData {
} }
fn add_image_assets(&self, assets: &mut Vec<Asset>) { fn add_image_assets(&self, assets: &mut Vec<Asset>) {
// Логотипы команд — НЕ кешируем (часто меняются)
if !self.team_a_logo.is_empty() { if !self.team_a_logo.is_empty() {
assets.push(Asset::Image { assets.push(Asset::Image {
src: self.team_a_logo.clone(), src: self.team_a_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "TEAM_A_LOGO".to_string(), layer_name: "TEAM_A_LOGO".to_string(),
cache: false,
}); });
if let Some(res) = &self.team_a_logo_res { if let Some(res) = &self.team_a_logo_res {
assets.push(Asset::DataExpression { assets.push(Asset::DataExpression {
@@ -498,6 +533,7 @@ impl JobData {
src: self.team_b_logo.clone(), src: self.team_b_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "TEAM_B_LOGO".to_string(), layer_name: "TEAM_B_LOGO".to_string(),
cache: false,
}); });
if let Some(res) = &self.team_b_logo_res { if let Some(res) = &self.team_b_logo_res {
assets.push(Asset::DataExpression { assets.push(Asset::DataExpression {
@@ -508,11 +544,13 @@ impl JobData {
}); });
} }
} }
// Логотип канала — КЕШИРУЕМ (редко меняется)
if !self.channel_logo.is_empty() { if !self.channel_logo.is_empty() {
assets.push(Asset::Image { assets.push(Asset::Image {
src: self.channel_logo.clone(), src: self.channel_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "CHANELL".to_string(), layer_name: "CHANELL".to_string(),
cache: true,
}); });
} }
} }
@@ -539,16 +577,36 @@ impl LogoRegistry {
} }
pub fn find(&self, team: &str, sport: &str) -> Option<String> { pub fn find(&self, team: &str, sport: &str) -> Option<String> {
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) { if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
log::debug!("Found exact match: {}", link);
return Some(link.clone()); return Some(link.clone());
} }
for ((t, s), link) in &self.logos { for ((t, s), link) in &self.logos {
if s == sport && team.starts_with(t) { if s == sport && team.starts_with(t) {
log::debug!("Found prefix match: t='{}', link='{}'", t, link);
return Some(link.clone()); return Some(link.clone());
} }
} }
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
None None
} }
/// Оставляет только указанные команды, удаляя все остальные
pub fn retain<F>(&mut self, mut predicate: F)
where
F: FnMut(&str, &str) -> bool,
{
self.logos.retain(|(team, sport), _| predicate(team, sport));
}
/// Возвращает количество записей в реестре
pub fn len(&self) -> usize {
self.logos.len()
}
} }
fn parse_date(s: &str) -> (NaiveDate, String) { fn parse_date(s: &str) -> (NaiveDate, String) {
@@ -604,10 +662,25 @@ fn unc_to_uri(path: &str) -> String {
if path.is_empty() { if path.is_empty() {
return String::new(); return String::new();
} }
// Уже правильный URI
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") { if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
return path.to_string(); return path.to_string();
} }
format!("file://{}", path.replace('\\', "/").trim_start_matches('/'))
// Mac SMB путь: smb://server/share/path -> file://server/share/path
if path.starts_with("smb://") {
let without_smb = path.strip_prefix("smb:").unwrap_or(path);
return format!("file:{}", without_smb.replace('\\', "/"));
}
// Windows UNC путь: \\server\share\path -> file://server/share/path
let normalized = path.replace('\\', "/");
// Убираем ведущие слеши (для UNC их быть не должно)
let trimmed = normalized.trim_start_matches('/');
format!("file://{}", trimmed)
} }
fn logo_scale_expression(target_size: &str) -> String { fn logo_scale_expression(target_size: &str) -> String {

460
src/processor.rs Normal file
View File

@@ -0,0 +1,460 @@
use crate::config::Config;
use crate::nexrender::{JobData, LogoRegistry};
use crate::synology::SynologyClient;
use crate::storage::{self, JobRecord};
use crate::web::WsEvent;
use anyhow::{anyhow, Result};
use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate, Utc};
use log::{debug, error, info};
use reqwest::Client;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::Path;
use tokio::sync::broadcast;
#[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)
}
}
pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx: broadcast::Sender<WsEvent>) -> 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);
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
display_workbook_structure(&workbook);
let submitted = generate_and_submit_jobs(&workbook, config, db, ws_tx).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)...");
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 {
format!("{:.0}", f)
} else {
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)) {
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()
);
}
}
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
if let Some(val) = row.get(key_part) {
if !val.is_empty() {
return Some(val.clone());
}
}
for (k, v) in row.iter() {
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
return Some(v.clone());
}
}
None
}
async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook,
config: &Config,
db: &sqlx::SqlitePool,
ws_tx: broadcast::Sender<WsEvent>,
) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs...");
// Load SPORT packs
let sport_sheet = workbook
.get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' 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());
// Load TEAM logos
let teams_sheet = workbook
.get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
for row in &teams_sheet.rows {
let team = get_cell_fuzzy(row, "TEAM");
let sport = get_cell_fuzzy(row, "SPORT");
let link = get_cell_fuzzy(row, "LINK");
if let (Some(team), Some(sport), Some(link)) = (team, sport, link) {
logos.insert(team.clone(), sport.clone(), link.clone());
}
}
info!("Loaded {} team logos", teams_sheet.rows.len());
// Load CHANNEL logos
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
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());
// Process START sheet
let start_sheet = workbook
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
let active_rows: Vec<(usize, &HashMap<String, String>)> = start_sheet
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE"))
.collect();
info!("Found {} active rows (STATE='FALSE') out of {} total", active_rows.len(), start_sheet.rows.len());
let mut used_teams: HashSet<(String, String)> = HashSet::new();
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
for (idx, row) in active_rows {
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
let sport = row.get("SPORT").cloned().unwrap_or_default();
if !team_a.is_empty() {
used_teams.insert((team_a.clone(), sport.clone()));
}
if !team_b.is_empty() {
used_teams.insert((team_b.clone(), sport.clone()));
}
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
jobs.extend(job.create_variants());
}
}
// Cleanup unused logos
let logos_before = logos.len();
logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string())));
info!("Retained {} used team logos (cleaned up {} unused)", logos.len(), logos_before - logos.len());
// Sort jobs by date and time
jobs.sort_by(|a, b| match a.sort_date.cmp(&b.sort_date) {
std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) {
std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order),
other => other,
},
other => other,
});
info!("Jobs sorted by date and time (earliest first)");
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());
for job in jobs {
let nexrender_job = job.to_nexrender_job(config);
info!("Submitting job: {}", job.outfile_name);
let response = http_client
.post(&config.nexrender_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()) {
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
// Save to database
let record = JobRecord {
uid: uid.to_string(),
filename: job.outfile_name.clone(),
status: "queued".to_string(),
output_path: format!("{}/{}.{}", config.output_folder, job.outfile_name, config.template_output_ext),
approved: false,
created_at: Utc::now(),
updated_at: Utc::now(),
};
if let Err(e) = storage::upsert_job(db, &record).await {
error!("Failed to save job to DB: {}", e);
} else {
let _ = ws_tx.send(WsEvent::JobUpdated(record));
}
}
} else {
let status = response.status();
let text = response.text().await.unwrap_or_default();
error!("Failed to submit job ({}): {}", status, text);
}
}
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())
}
}

BIN
src/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

8788
src/static/fontawesome/all.min.css vendored Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons — Управление заданиями</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body>
<div class="container">
<div class="header">
<div class="logo-area">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo">
<h1>AE Anons</h1>
</div>
<div class="theme-toggle">
<button id="themeToggleBtn" class="theme-toggle-btn" title="Сменить тему">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
<div class="controls">
<button id="generateBtn" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> Generate
</button>
<button id="cleanupBtn" class="btn btn-secondary">
<i class="fas fa-trash-alt"></i> Cleanup
</button>
<button id="stopAllBtn" class="btn btn-danger">
<i class="fas fa-stop-circle"></i> Stop all
</button>
<span id="statusMsg" class="status-msg"></span>
</div>
<div class="table-wrapper">
<table id="jobsTable">
<thead>
<tr>
<th data-sort="uid">UID <i class="fas fa-sort"></i></th>
<th data-sort="filename">Файл <i class="fas fa-sort"></i></th>
<th data-sort="status">Статус <i class="fas fa-sort"></i></th>
<th>Превью</th>
<th>Апрув</th>
<th data-sort="created_at">Создан <i class="fas fa-sort"></i></th>
</tr>
</thead>
<tbody id="jobsBody">
<tr>
<td colspan="6" class="loading">Загрузка заданий...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
// --- Тема (dark/light) с автоопределением ОС и кнопкой переключения ---
(function initTheme() {
const STORAGE_KEY = 'ae_anons_theme';
const btn = document.getElementById('themeToggleBtn');
const icon = btn.querySelector('i');
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
if (theme === 'dark') {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
} else {
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
}
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return null;
}
const saved = getSavedTheme();
const initial = saved !== null ? saved : getSystemTheme();
setTheme(initial);
btn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
// Следим за изменением системной темы (если нет сохранённой)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem(STORAGE_KEY) === null) {
setTheme(e.matches ? 'dark' : 'light');
}
});
})();
// --- Основная логика приложения (WebSocket, таблица, кнопки) ---
let ws = null;
let currentJobs = [];
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => console.log('WebSocket connected');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleWsEvent(data);
};
ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (err) => console.error('WebSocket error:', err);
}
function handleWsEvent(event) {
switch (event.type) {
case 'JobUpdated':
updateJobInList(event.job);
break;
case 'GenerationStarted':
showStatus('Генерация заданий началась...', 'info');
break;
case 'GenerationFinished':
showStatus(`Генерация завершена: создано ${event.count} заданий`, 'success');
loadJobs();
break;
case 'Error':
showStatus(`Ошибка: ${event.message}`, 'error');
break;
default:
console.log('Unknown event:', event);
}
}
function updateJobInList(job) {
const idx = currentJobs.findIndex(j => j.uid === job.uid);
if (idx !== -1) {
currentJobs[idx] = job;
} else {
currentJobs.unshift(job);
}
renderJobsTable();
}
async function loadJobs() {
try {
const response = await fetch('/api/jobs');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
currentJobs = await response.json();
renderJobsTable();
} catch (err) {
console.error('Failed to load jobs:', err);
document.getElementById('jobsBody').innerHTML = '<tr><td colspan="6">Ошибка загрузки заданий</td></tr>';
}
}
function renderJobsTable() {
const tbody = document.getElementById('jobsBody');
if (!currentJobs.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет заданий</td></tr>';
return;
}
tbody.innerHTML = currentJobs.map(job => `
<tr data-uid="${job.uid}">
<td class="uid">${escapeHtml(job.uid)}</td>
<td class="filename">${escapeHtml(job.filename)}</td>
<td class="status">${escapeHtml(job.status)}</td>
<td class="preview">
<a href="/api/video/${job.uid}" target="_blank" title="Просмотр видео">
<i class="fas fa-video"></i>
</a>
</td>
<td class="approve">
<button class="approve-btn" data-uid="${job.uid}" ${job.approved ? 'disabled' : ''}>
${job.approved ? '✅' : '✅'}
</button>
</td>
<td class="created">${formatDate(job.created_at)}</td>
</tr>
`).join('');
// привязать обработчики апрува
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const uid = btn.getAttribute('data-uid');
approveJob(uid, btn);
});
});
}
async function approveJob(uid, btn) {
try {
const response = await fetch(`/api/approve/${uid}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
showStatus(`Файл скопирован: ${result.destination || 'approved'}`, 'success');
btn.disabled = true;
// обновить задание в списке
const job = currentJobs.find(j => j.uid === uid);
if (job) { job.approved = true; renderJobsTable(); }
} else {
showStatus(`Ошибка: ${result.message || result.status}`, 'error');
}
} catch (err) {
showStatus(`Ошибка при апруве: ${err.message}`, 'error');
}
}
async function generateJobs() {
const btn = document.getElementById('generateBtn');
btn.disabled = true;
showStatus('Запуск генерации...', 'info');
try {
const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) {
showStatus('Генерация запущена', 'success');
} else {
const text = await response.text();
showStatus(`Ошибка: ${text}`, 'error');
}
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
} finally {
setTimeout(() => { btn.disabled = false; }, 2000);
}
}
async function cleanupJobs() {
showStatus('Очистка завершённых заданий...', 'info');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
showStatus('Очистка выполнена', 'success');
loadJobs();
} else {
showStatus('Ошибка при очистке', 'error');
}
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
}
}
async function stopAllJobs() {
if (!confirm('Остановить все активные задания?')) return;
showStatus('Остановка заданий...', 'info');
try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
const text = await response.text();
showStatus(text, 'success');
loadJobs();
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
}
}
function showStatus(msg, type) {
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.className = `status-msg ${type}`;
setTimeout(() => {
if (el.textContent === msg) el.textContent = '';
}, 5000);
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function (m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Инициализация
connectWebSocket();
loadJobs();
document.getElementById('generateBtn').addEventListener('click', generateJobs);
document.getElementById('cleanupBtn').addEventListener('click', cleanupJobs);
document.getElementById('stopAllBtn').addEventListener('click', stopAllJobs);
</script>
</body>
</html>

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

@@ -0,0 +1,273 @@
:root {
--bg-body: #f5f7fa;
--bg-container: #ffffff;
--text-primary: #1e2a3a;
--text-secondary: #4a5568;
--border-color: #e2e8f0;
--table-header-bg: #f8fafc;
--btn-primary-bg: #3b82f6;
--btn-primary-hover: #2563eb;
--btn-secondary-bg: #64748b;
--btn-secondary-hover: #475569;
--btn-danger-bg: #ef4444;
--btn-danger-hover: #dc2626;
--status-info: #3b82f6;
--status-success: #10b981;
--status-error: #ef4444;
--logo-brightness: 1;
}
[data-theme="dark"] {
--bg-body: #0f172a;
--bg-container: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--border-color: #334155;
--table-header-bg: #0f172a;
--btn-primary-bg: #2563eb;
--btn-primary-hover: #3b82f6;
--btn-secondary-bg: #475569;
--btn-secondary-hover: #64748b;
--btn-danger-bg: #dc2626;
--btn-danger-hover: #ef4444;
--logo-brightness: 0.9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
transition: background-color 0.3s, color 0.2s;
}
.container {
max-width: 1400px;
margin: 0 auto;
background-color: var(--bg-container);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
}
.logo-area {
display: flex;
align-items: center;
gap: 1rem;
}
.logo {
width: 150px;
/* уменьшенный размер */
height: auto;
filter: brightness(var(--logo-brightness));
}
.logo-area h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
}
.theme-toggle-btn {
background: none;
border: 1px solid var(--border-color);
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
font-size: 1.2rem;
color: var(--text-primary);
transition: all 0.2s;
}
.theme-toggle-btn:hover {
background-color: var(--border-color);
}
.controls {
padding: 1rem 2rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn i {
font-size: 1rem;
}
.btn-primary {
background-color: var(--btn-primary-bg);
color: white;
}
.btn-primary:hover {
background-color: var(--btn-primary-hover);
}
.btn-secondary {
background-color: var(--btn-secondary-bg);
color: white;
}
.btn-secondary:hover {
background-color: var(--btn-secondary-hover);
}
.btn-danger {
background-color: var(--btn-danger-bg);
color: white;
}
.btn-danger:hover {
background-color: var(--btn-danger-hover);
}
.btn:active {
transform: scale(0.97);
}
.status-msg {
margin-left: auto;
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
}
.status-msg.info {
background-color: var(--status-info);
color: white;
}
.status-msg.success {
background-color: var(--status-success);
color: white;
}
.status-msg.error {
background-color: var(--status-error);
color: white;
}
.table-wrapper {
overflow-x: auto;
padding: 0 2rem 2rem 2rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--table-header-bg);
font-weight: 600;
cursor: pointer;
user-select: none;
}
th i {
margin-left: 0.5rem;
opacity: 0.5;
}
tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
[data-theme="dark"] tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.approve-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
transition: transform 0.1s;
}
.approve-btn:not(:disabled):hover {
transform: scale(1.1);
}
.approve-btn:disabled {
opacity: 0.5;
cursor: default;
}
.preview a {
color: var(--text-primary);
text-decoration: none;
font-size: 1.2rem;
}
.preview a:hover {
opacity: 0.7;
}
@media (max-width: 768px) {
.header,
.controls,
.table-wrapper {
padding-left: 1rem;
padding-right: 1rem;
}
.logo {
width: 100px;
}
.logo-area h1 {
font-size: 1.2rem;
}
th,
td {
padding: 0.5rem;
}
}

141
src/storage.rs Normal file
View File

@@ -0,0 +1,141 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone, Serialize)]
pub struct JobRecord {
pub uid: String,
pub filename: String,
pub status: String,
pub output_path: String,
pub approved: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
// Для in-memory БД (тестирование)
if database_url == "sqlite::memory:" {
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS jobs (
uid TEXT PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL,
output_path TEXT NOT NULL,
approved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"#,
)
.execute(&pool)
.await?;
return Ok(pool);
}
// Для файловой БД
let path = database_url.strip_prefix("sqlite:").unwrap_or(database_url);
// Создаём родительскую директорию, если её нет
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("Failed to create database directory '{}': {}", parent.display(), e))?;
}
}
// Подключаемся (если файла нет, SQLite создаст его автоматически)
let pool = SqlitePool::connect(database_url).await
.map_err(|e| anyhow::anyhow!("Failed to open database '{}': {}", database_url, e))?;
// Создаём таблицу, если её нет
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS jobs (
uid TEXT PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL,
output_path TEXT NOT NULL,
approved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
}
pub async fn upsert_job(pool: &SqlitePool, record: &JobRecord) -> Result<()> {
sqlx::query(
r#"
INSERT INTO jobs (uid, filename, status, output_path, approved, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uid) DO UPDATE SET
status = excluded.status,
output_path = excluded.output_path,
approved = excluded.approved,
updated_at = excluded.updated_at
"#,
)
.bind(&record.uid)
.bind(&record.filename)
.bind(&record.status)
.bind(&record.output_path)
.bind(record.approved)
.bind(record.created_at)
.bind(record.updated_at)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_all_jobs(pool: &SqlitePool) -> Result<Vec<JobRecord>> {
let rows = sqlx::query("SELECT * FROM jobs ORDER BY created_at DESC")
.fetch_all(pool)
.await?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(JobRecord {
uid: row.get("uid"),
filename: row.get("filename"),
status: row.get("status"),
output_path: row.get("output_path"),
approved: row.get("approved"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
});
}
Ok(jobs)
}
pub async fn get_job(pool: &SqlitePool, uid: &str) -> Result<Option<JobRecord>> {
let row = sqlx::query("SELECT * FROM jobs WHERE uid = ?")
.bind(uid)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(JobRecord {
uid: row.get("uid"),
filename: row.get("filename"),
status: row.get("status"),
output_path: row.get("output_path"),
approved: row.get("approved"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}))
} else {
Ok(None)
}
}
pub async fn approve_job(pool: &SqlitePool, uid: &str) -> Result<bool> {
let result = sqlx::query("UPDATE jobs SET approved = 1, updated_at = ? WHERE uid = ?")
.bind(Utc::now())
.bind(uid)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

489
src/web.rs Normal file
View File

@@ -0,0 +1,489 @@
use crate::config::Config;
use crate::processor::{fetch_all_jobs, process_spreadsheet};
use crate::storage::{self, JobRecord};
use axum::{
extract::{Path, State, WebSocketUpgrade},
http::{header::CONTENT_TYPE, StatusCode},
response::{Html, IntoResponse, Json, Response},
routing::{get, post},
Router,
};
use serde::Serialize;
use serde_json::json;
use smb::{Client, ClientConfig, CreateOptions, FileAttributes, FileCreateArgs, UncPath, WriteAt};
use sqlx::SqlitePool;
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_util::io::ReaderStream;
use tower_http::trace::TraceLayer;
use url::Url;
use chrono::Utc;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub db: SqlitePool,
pub ws_tx: broadcast::Sender<WsEvent>,
pub last_generation: Arc<tokio::sync::Mutex<Option<chrono::DateTime<chrono::Local>>>>,
}
#[derive(Serialize, Clone)]
#[serde(tag = "type")]
pub enum WsEvent {
JobUpdated(JobRecord),
GenerationStarted,
GenerationFinished { count: usize },
Error { message: String },
}
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
let web_port = config.web_port;
let db = storage::init_db(&config.database_url).await?;
let (ws_tx, _) = broadcast::channel(100);
let state = AppState {
config,
db,
ws_tx,
last_generation: Arc::new(tokio::sync::Mutex::new(None)),
};
let state_clone_for_sync = state.clone();
tokio::spawn(async move {
sync_jobs_status(state_clone_for_sync).await;
});
let app = Router::new()
.route("/", get(index_page))
.route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_css))
.route("/assets/logo.png", get(logo_png))
.route("/api/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs))
.route("/api/video/{uid}", get(serve_video))
.route("/api/approve/{uid}", post(approve_job))
.route("/ws", get(ws_handler))
.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 sync_jobs_status(state: AppState) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
log::debug!("🔄 Syncing jobs from Nexrender...");
match fetch_all_jobs(&state.config.nexrender_api_url).await {
Ok(jobs_json) => {
log::info!("📊 Fetched {} jobs from Nexrender", jobs_json.len());
for job_json in jobs_json {
if let Some(job) = JobInfo::from_nexrender_json(&job_json) {
log::debug!("🔍 Job from Nexrender: uid={}, state={}", job.uid, job.state);
match storage::get_job(&state.db, &job.uid).await {
Ok(Some(record)) => {
if record.status != job.state {
log::info!("🔄 Status changed for job {}: {} -> {}", job.uid, record.status, job.state);
let mut updated_record = record;
updated_record.status = job.state.clone();
updated_record.updated_at = Utc::now();
if let Err(e) = storage::upsert_job(&state.db, &updated_record).await {
log::error!("❌ Failed to update job status in DB: {}", e);
} else {
let _ = state.ws_tx.send(WsEvent::JobUpdated(updated_record));
log::debug!("📨 Sent WebSocket event for job {}", job.uid);
}
} else {
log::debug!("✅ No change for job {}", job.uid);
}
}
Ok(None) => {
log::warn!("⚠️ Job {} not found in DB, skipping", job.uid);
}
Err(e) => {
log::error!("❌ DB error for job {}: {}", job.uid, e);
}
}
} else {
log::warn!("⚠️ Failed to parse job from JSON: {:?}", job_json);
}
}
}
Err(e) => {
log::error!("❌ Failed to fetch jobs from Nexrender: {}", e);
}
}
}
}
async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html"))
}
async fn style_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/style.css"),
)
}
async fn fontawesome_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/fontawesome/all.min.css"),
)
}
async fn logo_png() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/png")],
include_bytes!("../assets/logo.png").as_slice(),
)
}
async fn favicon() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/x-icon")],
include_bytes!("static/favicon.ico").as_slice(),
)
}
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobRecord>>, AppError> {
let jobs = storage::get_all_jobs(&state.db)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
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 state_clone = state.clone();
tokio::spawn(async move {
let _ = state_clone.ws_tx.send(WsEvent::GenerationStarted);
match process_spreadsheet(
&state_clone.config,
&state_clone.db,
state_clone.ws_tx.clone(),
)
.await
{
Ok(submitted) => {
log::info!("Generation completed, {} jobs submitted", submitted.len());
let _ = state_clone.ws_tx.send(WsEvent::GenerationFinished {
count: submitted.len(),
});
}
Err(e) => {
log::error!("Generation failed: {}", e);
let _ = state_clone.ws_tx.send(WsEvent::Error {
message: e.to_string(),
});
}
}
*state_clone.last_generation.lock().await = None;
});
Ok((StatusCode::ACCEPTED, "Job generation started"))
}
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let client = reqwest::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 removed = 0;
for job in jobs {
if let (Some(uid), Some(status)) = (
job.get("uid").and_then(|u| u.as_str()),
job.get("state").and_then(|s| s.as_str()),
) {
if status == "finished" || status == "error" {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
// Also delete from local DB
let _ = sqlx::query("DELETE FROM jobs WHERE uid = ?")
.bind(uid)
.execute(&state.db)
.await;
removed += 1;
log::info!("Cleaned up job: {}", uid);
}
}
}
Ok((StatusCode::OK, format!("Cleaned up {} jobs", removed)))
}
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let last_gen = *state.last_generation.lock().await;
let status = 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_str = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state_str == "queued" || state_str == "started" || state_str == "processing" {
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
stopped += 1;
log::info!("Stopped job: {}", uid);
}
}
}
log::info!("Stopped {} active jobs", stopped);
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
}
async fn serve_video(
Path(uid): Path<String>,
State(state): State<AppState>,
) -> Result<Response, AppError> {
let record = storage::get_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
let path = std::path::Path::new(&record.output_path);
if !path.exists() {
return Err(AppError(
StatusCode::NOT_FOUND,
"File not found".to_string(),
));
}
let mime = mime_guess::from_path(path).first_or_octet_stream();
let file = tokio::fs::File::open(path)
.await
.map_err(|_| AppError(StatusCode::NOT_FOUND, "Cannot open file".to_string()))?;
let stream = ReaderStream::new(file);
let body = axum::body::Body::from_stream(stream);
Ok(Response::builder()
.header(CONTENT_TYPE, mime.as_ref())
.body(body)
.unwrap())
}
async fn approve_job(
Path(uid): Path<String>,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut record = storage::get_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
if record.approved {
return Ok(Json(json!({"status": "already_approved"})));
}
let smb_url = state.config.smb_upload_url.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_UPLOAD_URL not configured".to_string(),
)
})?;
let smb_user = state.config.smb_username.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_USERNAME not configured".to_string(),
)
})?;
let smb_pass = state.config.smb_password.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_PASSWORD not configured".to_string(),
)
})?;
// *** ИСПРАВЛЕНИЕ ЗДЕСЬ ***
let src_path = std::path::Path::new(&state.config.smb_source_folder).join(&record.filename);
if !src_path.exists() {
return Err(AppError(
StatusCode::NOT_FOUND,
format!("Source file not found: {}", src_path.display()),
));
}
// Исправление для SMB URL обработки
let url = Url::parse(smb_url)
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid SMB URL: {}", e)))?;
let server = url
.host_str()
.ok_or_else(|| AppError(StatusCode::BAD_REQUEST, "No host in SMB URL".to_string()))?;
// Исправленный способ извлечения share и path
let path_parts: Vec<&str> = url.path().trim_matches('/').split('/').collect();
if path_parts.is_empty() {
return Err(AppError(
StatusCode::BAD_REQUEST,
"No share in SMB URL".to_string(),
));
}
// Первый элемент пути - это имя шары
let share = path_parts[0];
// Остальная часть пути будет использоваться как путь к файлу (если есть)
let remote_path = if path_parts.len() > 1 {
path_parts.iter().skip(1).map(|&s| s.to_string()).collect::<Vec<_>>().join("/")
} else {
String::new()
};
// Исправленное подключение к SMB
let client = Client::new(ClientConfig::default());
// Подключаемся к шаре
let target_path = UncPath::from_str(&format!("\\\\{}\\{}", server, share))
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid UNC path: {}", e)))?;
client
.share_connect(&target_path, smb_user, smb_pass.clone())
.await
.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("SMB connect failed: {}", e),
)
})?;
// Читаем содержимое файла
let data = tokio::fs::read(&src_path).await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read source failed: {}", e),
)
})?;
// Определяем путь для сохранения на SMB
let remote_filename = if !remote_path.is_empty() {
format!("{}/{}", remote_path, record.filename)
} else {
record.filename.clone()
};
// Создаем полный UNC путь к файлу в шаре
let file_to_open = target_path.with_path(&remote_filename);
let file_open_args =
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
let resource = client
.create_file(&file_to_open, &file_open_args)
.await
.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Create file failed: {}", e),
)
})?;
let remote_file = resource.unwrap_file();
// Записываем данные на SMB
remote_file.write_at(&data, 0).await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Write to SMB failed: {}", e),
)
})?;
remote_file.close().await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Close file failed: {}", e),
)
})?;
// Обновляем статус в БД
storage::approve_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
record.approved = true;
// Отправляем обновление через WebSocket
let _ = state.ws_tx.send(WsEvent::JobUpdated(record.clone()));
Ok(Json(json!({"status": "approved"})))
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.ws_tx.subscribe();
while let Ok(event) = rx.recv().await {
if let Ok(text) = serde_json::to_string(&event) {
if socket
.send(axum::extract::ws::Message::Text(text.into()))
.await
.is_err()
{
break;
}
}
}
}
#[derive(Debug, Clone)]
struct JobInfo {
uid: String,
state: 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();
Some(JobInfo {
uid,
state,
})
}
}
struct AppError(StatusCode, String);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.0, self.1).into_response()
}
}