17 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
21 changed files with 13029 additions and 1644 deletions

View File

@@ -1,22 +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"
# Logging (optional, defaults to info) # Logging
RUST_LOG="info" RUST_LOG="info"
# Web Server (optional, defaults to 3000) # Web Server
WEB_PORT="3000" 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.aep" TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep" 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

2659
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.2.2" 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,31 +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 (updated to latest stable versions) # Web server (with WebSocket support)
axum = "0.8.9" axum = { version = "0.8.9", features = ["ws"] }
tower = "0.5" tower = "0.5.2"
tower-http = { version = "0.6", features = ["fs", "trace"] } tower-http = { version = "0.6.4", features = ["fs", "trace"] }
askama = "0.15" askama = "0.15.4"
clap = { version = "4.5", features = ["derive"] } 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"] }
@@ -49,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"

666
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">
@@ -10,21 +10,22 @@
[![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) [![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) [![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, рендеринг выполняется локально, управление заданиями происходит через встроенный веб-интерфейс с мгновенными обновлениями. Система не требует доступа в интернет — все ресурсы (иконки, стили, шрифты) встроены в бинарный файл.
## Новое в версии 0.2.2 ## Новое в версии 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).
### Режимы работы ### Режимы работы
@@ -35,154 +36,147 @@ After Effects (лицензия MIT).
- Мониторинг завершения и выход - Мониторинг завершения и выход
2. **Веб-сервер** (`--web`): 2. **Веб-сервер** (`--web`):
- Запуск веб-интерфейса на порту `:3000` - Запуск веб-интерфейса на порту `:3000` (настраивается)
- Управление заданиями через браузер - Управление заданиями через браузер с WebSocket-обновлениями
- Автообновление статуса каждую минуту - Просмотр видео и апрув готовых роликов
## Особенности ## Особенности
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology - **Полная автономность** — не требует выхода в интернет, все ресурсы встроены в бинарник
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel - **Интеграция с Synology** — аутентификация и загрузка файлов с NAS Synology (локальные и AD-учётные записи)
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков - **Экспорт офисных таблиц** — автоматическое преобразование `.osheet` в Excel
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий - **Гибкий парсинг данных** — динамический парсинг листов с обнаружением заголовков
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов - **Множественная генерация вариантов** — "Сегодня", "Завтра" и датированные версии
- **Веб-интерфейс**: Удобное управление и мониторинг заданий - **Умное управление логотипами** — автоматическое разрешение и масштабирование
- **REST API**: Программный доступ к управлению заданиями - **Веб-интерфейс с WebSocket** — мгновенные обновления, просмотр видео, апрув
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня - **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 (формат Adobe After Effects 2024 .aepx) - Шаблоны After Effects (формат .aep или .aepx, совместимые с версией AE на worker)
- (Для апрува) Доступ к SMB-шаре с правами на запись
## Установка ## Установка
### 1. Клонирование репозитория git clone https://git.example.com/team/ae_anons.git
cd ae_anons
```bash cargo build --release
git clone https://git.tvstart.ru/lexx/AE_Anons.git cp .env.example .env
cd ae_anons # отредактируйте .env согласно вашей инфраструктуре
```
### 2. Сборка проекта
```bash
cargo build --release
```
### 3. Настройка окружения
Скопируйте пример конфигурации и заполните своими данными:
```bash
cp .env.example .env
```
Затем отредактируйте файл .env:
См. раздел Конфигурация для подробной информации.
## Использование
### Однократная обработка
```bash
# Запуск с настройками по умолчанию
./target/release/ae_anons
# Явно указать однократный режим
./target/release/ae_anons --once
# С отладочным логированием
RUST_LOG=debug ./target/release/ae_anons
```
### Веб-сервер
```bash
# Запуск веб-интерфейса на порту по умолчанию (3000)
./target/release/ae_anons --web
# Или с указанием другого порта через .env файл
# WEB_PORT=8080 ./target/release/ae_anons --web
```
## Веб-интерфейс
После запуска веб-сервера откройте браузер:
- Главная страница: <http://localhost:3000>
- API эндпоинты:
- `GET /api/jobs` - список всех заданий
- `POST /api/generate` - запуск генерации
- `POST /api/cleanup` - очистка завершённых
- `POST /api/jobs/stop-all` - остановка активных
- `GET /api/status` - статус сервера
## Уровни логирования
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
- **error** - Только критические ошибки
- **warn** - Предупреждения и ошибки
- **info** - Общие операционные сообщения (по умолчанию)
- **debug** - Детальная информация о процессе обработки
- **trace** - Полное отладочное логирование с деталями API вызовов
```bash
RUST_LOG=debug cargo run
```
## Конфигурация ## Конфигурация
### Переменные окружения Все настройки задаются через переменные окружения в файле `.env`.
| Переменная | Обязательна | Описание | ### Основные переменные
|-------------------------|-------------|------------------------------------------------------|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом | | Переменная | Обязательна | Описание |
| `NAS_USER` | Да | Имя пользователя учетной записи Synology | |-------------------------|-------------|---------------------------------------------------------------------------------|
| `NAS_PASS` | Да | Пароль учетной записи Synology | | `NAS_FQDN` | Да | URL Synology NAS (*) |
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS | | `NAS_USER` | Да | Имя пользователя (локального или доменного, например `DOMAIN\username`) |
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender | | `NAS_PASS` | Да | Пароль |
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео | | `NAS_FILE` | Да | Путь к `.osheet` файлу на NAS (например, `/Team Folder/schedule.osheet`) |
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд | | `NEXRENDER_API_URL` | Да | API Nexrender (например, `http://nexrender:3050/api/v1/jobs`) |
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды | | `OUTPUT_FOLDER` | Да | Папка, куда Nexrender сохраняет готовые видео (локальный путь) |
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) | | `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд (например, `file:///templates/double.aepx`) |
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) | | `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) | | `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE |
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) | | `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE |
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) | | `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://nas.company.local:5001"
NAS_FQDN="https://your-nas.example.com" NAS_USER="DOMAIN\\service_account"
NAS_USER="your_username" NAS_PASS="strongpassword"
NAS_PASS="your_password" NAS_FILE="/Team Folder/Anonsy/sport.osheet"
NAS_FILE="/Team Folder/path/to/file.osheet"
# Logging # Nexrender
RUST_LOG="info" NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
OUTPUT_FOLDER="/mnt/nexrender/output"
# Web Server # After Effects Templates
WEB_PORT="3000" TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
TEMPLATE_COMPOSITION="main"
TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4"
# Nexrender # Web server
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs" WEB_PORT="3000"
OUTPUT_FOLDER="/path/to/output" RUST_LOG="info"
# After Effects Templates # SMB для апрува (AD-совместимо)
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx" SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx" SMB_USERNAME="DOMAIN\\service_account"
TEMPLATE_COMPOSITION="main" SMB_PASSWORD="strongpassword"
TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4" # 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** — максимальная детализация
## Структура электронной таблицы ## Структура электронной таблицы
@@ -241,12 +235,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
Имена команд поддерживают два типа разделителей хэш #: Имена команд поддерживают два типа разделителей хэш #:
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта. 1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
Примеры: 2. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
Формат: `DisplayName#UniqueID#TargetSize` Формат: `DisplayName#UniqueID#TargetSize`
@@ -256,389 +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» в веб-интерфейсе.
3. Сервер аутентифицируется на NAS, скачивает и парсит Excel.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4` 4. Генерирует задания Nexrender, сохраняет их в SQLite и отправляет в Nexrender.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4` 5. WebSocket уведомляет клиент о новых заданиях.
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4` 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 — осталась.
### Файлы шаблонов
Настраиваются через переменные окружения:
| Переменная | Назначение | Пример значения |
|-------------------------|-------------------------------------|------------------------------------|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
### Обязательные слои в шаблонах
#### Текстовые слои
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|-----------|:-------------:|:-------------:|-------------------------------------------------|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
| `SPORT` | ✅ | ✅ | Категория спорта |
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
#### Слои с изображениями
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|----------------|:-------------:|:-------------:|-----------------------------|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
#### Видео слои
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|-----------|:-------------:|:-------------:|----------------------------|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
### Особенности шаблонов
#### Шаблон DOUBLE (две команды)
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
Обязательные слои:
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
#### Шаблон SINGLE (одна команда)
Используется когда заполнено только одно поле команды.
Обязательные слои:
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
- Только логотип `TEAM_A_LOGO`
### Автоматические корректировки
AE Anons автоматически применяет следующие настройки к слоям:
#### Слой DATA
В зависимости от отображаемого текста и типа шаблона:
| Текст | Шаблон | Font Size | Anchor Point |
|------------|----------|-----------|--------------|
| "сегодня" | DOUBLE | 105 | [0, 5] |
| "завтра" | DOUBLE | 115 | [0, 25] |
| дата (<6) | DOUBLE | 120 | [0, 20] |
#### Слои TIME_H, TIME_M, TIME
Корректировка Anchor Point в зависимости от длины текста:
| Условие | Anchor Point |
|-------------------|--------------|
| 1 символ | [60, 0] |
| 2 символа, <20 | [20, 0] |
#### Слой LEAGUE
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
#### Слой TEAMS
Если суммарная длина имён команд 32 символов, размер шрифта уменьшается до 55.
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
```javascript
if (width > height) {
max_size = width;
} else {
max_size = height;
}
var real_size = 550 / max_size * 100;
[real_size, real_size]
```
### Пример структуры слоёв в After Effects
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
```text
📁 main (композиция)
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
├── 📝 DATA (текстовый слой) # Дата (обязательный)
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
```
### Формат проекта и совместимость с After Effects
**Поддерживаемые форматы проектов:**
- `.aep` стандартный бинарный формат After Effects (рекомендуется)
- `.aepx` XML-формат проекта (поддерживается с AE CC 2015)
**Совместимость версий After Effects:**
| Версия After Effects | Поддерживаемые форматы | Особенности |
|----------------------|------------------------|-------------------------------------------------|
| CS 5.5 | `.aep` | Базовая поддержка |
| CC / CC 2014 | `.aep` | Полная поддержка |
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
> **⚠️ Важно для After Effects 2023+:**
>
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
>
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
### Настройки композиции
| Параметр | Переменная окружения | Пример значения | Описание |
|---------------------------|--------------------------|-----------------|---------------------------------------------|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
**Рекомендации по выбору формата:**
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
**Источники:**
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
## Устранение неисправностей ## Устранение неисправностей
### Частые проблемы ### 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 управляет очередью заданий внутренне
## Примечания по безопасности
- Храните учетные данные только в файле `.env` (исключен из git)
- Используйте специальные аккаунты с минимально необходимыми правами
- Сессии Synology завершаются после выполнения
- HTTPS рекомендуется для подключений NAS в продакшене
## Разработка ## Разработка
### Структура кода Все статические ресурсы (HTML, CSS, шрифты Font Awesome, логотип, favicon) встроены в бинарник с помощью `include_str!` и `include_bytes!`. Для разработки можно редактировать файлы в `src/static/`, но при сборке они компилируются внутрь исполняемого файла.
```shell ### Сборка для production
ae_anons/
├── .gitignore
├── .env.example
├── Cargo.toml
├── LICENSE
├── README.md
├── assets/
│ └── logo.png
└── src/
├── main.rs # Точка входа, CLI
├── config.rs # Конфигурация из .env
├── nexrender.rs # Модели заданий Nexrender
├── synology.rs # Клиент Synology API
├── processor.rs # Логика обработки
├── web.rs # Веб-сервер и API
└── static/
├── index.html # Веб-интерфейс
└── style.css # Стили
```
## Зависимости cargo build --release
strip target/release/ae_anons # уменьшает размер
### Основные зависимости Итоговый бинарник можно переносить на любой Linux-сервер без дополнительных зависимостей (кроме libc и openssl, если не используется статическая сборка).
| Crate | Версия | Назначение |
|--------------------|---------|-----------------------------------------|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
| chrono | 0.4 | Обработка дат и времени |
| tokio | 1.0 | Асинхронная среда выполнения |
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
| thiserror | 2.0 | Эргономичные определения типов ошибок |
| anyhow | 1.0 | Упрощённая обработка ошибок |
| regex | 1.11 | Регулярные выражения |
| urlencoding | 2.1 | Кодирование URL для API запросов |
| bytes | 1.9 | Работа с байтовыми данными |
| futures | 0.3 | Асинхронные примитивы |
### Веб-сервер и CLI
| Crate | Версия | Назначение |
|--------------------|--------|-----------------------------------------|
| axum | 0.8 | Веб-фреймворк для REST API |
| tower | 0.5 | Промежуточное ПО для веб-сервера |
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
| askama | 0.15 | Шаблонизация (опционально) |
| clap | 4.5 | Парсинг аргументов командной строки |
### Платформозависимые зависимости
| Crate | Версия | Платформа | Назначение |
|--------------------|--------|-----------|----------------------------------|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
## 🙏 Благодарности
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
## Лицензия ## Лицензия
**AE Anons** [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.2.2 (текущая)
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
--- ---

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,19 +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_serwer // Web
pub web_port: u16, 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")?,
@@ -33,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")
@@ -48,6 +57,12 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string()) .unwrap_or_else(|_| "3000".to_string())
.parse() .parse()
.context("Invalid WEB_PORT")?, .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,46 +1,42 @@
mod config; mod config;
mod nexrender; mod nexrender;
mod processor; mod processor;
mod storage;
mod synology; mod synology;
mod web; mod web;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use config::Config; use config::Config;
use dotenv::dotenv;
use log::info; use log::info;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Cli { struct Cli {
/// Run web server instead of one-time processing
#[arg(short, long)] #[arg(short, long)]
web: bool, web: bool,
/// Run one-time processing (default if no flags)
#[arg(short, long)] #[arg(short, long)]
once: bool, once: bool,
} }
#[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();
let cli = Cli::parse(); let cli = Cli::parse();
let config = Config::from_env()?; let config = Config::from_env()?;
if cli.web { if cli.web {
info!( info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION"));
"Starting AE Anons web server v{}",
env!("CARGO_PKG_VERSION")
);
web::run_web_server(config).await?; web::run_web_server(config).await?;
} else { } else {
// По умолчанию или с флагом --once выполняем однократную обработку info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION"));
info!( // For one-time mode we don't need SMB, so we create dummy db and channel
"Starting AE Anons processor v{} (one-time mode)", let (dummy_ws_tx, _) = tokio::sync::broadcast::channel(1);
env!("CARGO_PKG_VERSION") let dummy_db = sqlx::SqlitePool::connect("sqlite::memory:").await?;
); let submitted = processor::process_spreadsheet(&config, &dummy_db, dummy_ws_tx).await?;
let submitted = processor::process_spreadsheet(&config).await?;
info!("Submitted {} jobs. Exiting.", submitted.len()); info!("Submitted {} jobs. Exiting.", submitted.len());
} }

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,19 +303,34 @@ 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
} }
@@ -338,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,
}); });
} }
@@ -349,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(),
@@ -357,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(),
@@ -372,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(),
@@ -380,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(),
@@ -388,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(),
@@ -397,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(),
@@ -477,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 {
@@ -497,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 {
@@ -507,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,
}); });
} }
} }
@@ -538,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) {
@@ -603,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 {

View File

@@ -1,15 +1,18 @@
use crate::config::Config; use crate::config::Config;
use crate::nexrender::{JobData, LogoRegistry}; use crate::nexrender::{JobData, LogoRegistry};
use crate::synology::SynologyClient; use crate::synology::SynologyClient;
use crate::storage::{self, JobRecord};
use crate::web::WsEvent;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use calamine::{Data, Reader, Xlsx}; use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate}; use chrono::{Duration, NaiveDate, Utc};
use log::{debug, error, info}; use log::{debug, error, info};
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::io::Cursor; use std::io::Cursor;
use std::path::Path; use std::path::Path;
use tokio::sync::broadcast;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SheetData { pub struct SheetData {
@@ -29,8 +32,7 @@ impl ExcelWorkbook {
} }
} }
/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx: broadcast::Sender<WsEvent>) -> Result<Vec<(String, String)>> {
pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)>> {
let mut client = SynologyClient::new(&config.nas_fqdn); let mut client = SynologyClient::new(&config.nas_fqdn);
client.login(&config.nas_user, &config.nas_pass).await?; client.login(&config.nas_user, &config.nas_pass).await?;
info!("Successfully authenticated with Synology NAS"); info!("Successfully authenticated with Synology NAS");
@@ -38,11 +40,10 @@ pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)
let info = client.get_info().await?; let info = client.get_info().await?;
info!("Connected to NAS: {}", info.hostname); info!("Connected to NAS: {}", info.hostname);
// Скачиваем и парсим Excel в памяти
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?; let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
display_workbook_structure(&workbook); display_workbook_structure(&workbook);
let submitted = generate_and_submit_jobs(&workbook, config).await?; let submitted = generate_and_submit_jobs(&workbook, config, db, ws_tx).await?;
client.logout().await?; client.logout().await?;
info!("Session terminated successfully"); info!("Session terminated successfully");
@@ -105,7 +106,6 @@ async fn download_and_parse_excel_in_memory(
info!("Found file: {}", actual_file_name); info!("Found file: {}", actual_file_name);
info!("Exporting file from Synology Office to Excel format (in-memory)..."); 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?; let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
info!("Exported {} bytes to memory", excel_data.len()); info!("Exported {} bytes to memory", excel_data.len());
@@ -158,7 +158,6 @@ fn parse_sheet_dynamic_optimized(
}); });
} }
// Извлекаем заголовки с дедупликацией
let headers: Vec<String> = data_matrix[0] let headers: Vec<String> = data_matrix[0]
.iter() .iter()
.enumerate() .enumerate()
@@ -192,7 +191,7 @@ fn parse_sheet_dynamic_optimized(
None None
} }
}) })
.take(10000) // Ограничение для безопасности .take(10000)
.collect(); .collect();
Ok(SheetData { Ok(SheetData {
@@ -211,10 +210,8 @@ fn cell_to_string_optimized(cell: &Data) -> String {
if is_excel_date(*f) { if is_excel_date(*f) {
excel_date_to_string(*f) excel_date_to_string(*f)
} else if f.fract() == 0.0 { } else if f.fract() == 0.0 {
// Используем itoa для целых чисел (опционально)
format!("{:.0}", f) format!("{:.0}", f)
} else { } else {
// Используем ryu для float (опционально)
f.to_string() f.to_string()
} }
} }
@@ -256,7 +253,6 @@ fn excel_date_to_string(serial: f64) -> String {
let days = serial as i64; let days = serial as i64;
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap(); let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
if let Some(date) = base.checked_add_signed(Duration::days(days)) { if let Some(date) = base.checked_add_signed(Duration::days(days)) {
// Используем метод format напрямую - он публичный
date.format("%d.%m.%Y").to_string() date.format("%d.%m.%Y").to_string()
} else { } else {
serial.to_string() serial.to_string()
@@ -277,25 +273,32 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
} }
} }
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( async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook, workbook: &ExcelWorkbook,
config: &Config, config: &Config,
db: &sqlx::SqlitePool,
ws_tx: broadcast::Sender<WsEvent>,
) -> Result<Vec<(String, String)>> { ) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs..."); info!("Preparing Nexrender jobs...");
let start_sheet = workbook // Load SPORT packs
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
let sport_sheet = workbook let sport_sheet = workbook
.get_sheet("SPORT") .get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; .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 let packs: HashMap<String, String> = sport_sheet
.rows .rows
.iter() .iter()
@@ -303,16 +306,25 @@ async fn generate_and_submit_jobs(
.collect(); .collect();
info!("Loaded {} sport packs", packs.len()); 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()); let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
for row in &teams_sheet.rows { for row in &teams_sheet.rows {
if let (Some(team), Some(sport), Some(link)) = let team = get_cell_fuzzy(row, "TEAM");
(row.get("TEAM"), row.get("SPORT"), row.get("LINK")) 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()); logos.insert(team.clone(), sport.clone(), link.clone());
} }
} }
info!("Loaded {} team logos", teams_sheet.rows.len()); 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 let channels: HashMap<String, String> = channel_sheet
.rows .rows
.iter() .iter()
@@ -320,19 +332,50 @@ async fn generate_and_submit_jobs(
.collect(); .collect();
info!("Loaded {} channel logos", channels.len()); info!("Loaded {} channel logos", channels.len());
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3); // 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());
for (idx, row) in start_sheet.rows.iter().enumerate() { let mut used_teams: HashSet<(String, String)> = HashSet::new();
if let Some(state) = row.get("STATE") { let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
if state == "FALSE" {
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { for (idx, row) in active_rows {
jobs.extend(job.create_variants()); 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());
} }
} }
info!("Generated {} total jobs (including variants)", jobs.len()); // 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() { if jobs.is_empty() {
info!("No jobs with STATE='FALSE' found"); info!("No jobs with STATE='FALSE' found");
@@ -340,68 +383,54 @@ async fn generate_and_submit_jobs(
} }
cleanup_finished_jobs(&config.nexrender_api_url).await?; cleanup_finished_jobs(&config.nexrender_api_url).await?;
let http_client = Client::new(); let http_client = Client::new();
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
let mut tasks = Vec::with_capacity(jobs.len());
for job in jobs { for job in jobs {
let client = http_client.clone(); let nexrender_job = job.to_nexrender_job(config);
let api_url = config.nexrender_api_url.clone(); info!("Submitting job: {}", job.outfile_name);
let config_clone = config.clone(); let response = http_client
.post(&config.nexrender_api_url)
.json(&nexrender_job)
.send()
.await?;
tasks.push(tokio::spawn(async move { if response.status().is_success() {
let nexrender_job = job.to_nexrender_job(&config_clone); let result: Value = response.json().await?;
info!("Submitting job: {}", job.outfile_name); 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()));
let response = client.post(&api_url).json(&nexrender_job).send().await?; // Save to database
let record = JobRecord {
if response.status().is_success() { uid: uid.to_string(),
let result: Value = response.json().await?; filename: job.outfile_name.clone(),
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) { status: "queued".to_string(),
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name))) 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 { } else {
Ok(None) 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);
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);
} }
} else {
let status = response.status();
let text = response.text().await.unwrap_or_default();
error!("Failed to submit job ({}): {}", status, text);
} }
} }
info!( info!("Successfully submitted {} jobs to Nexrender", submitted_jobs.len());
"Successfully submitted {} jobs to Nexrender",
submitted_jobs.len()
);
Ok(submitted_jobs) Ok(submitted_jobs)
} }
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> { pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
let client = Client::new(); let client = Client::new();
let response = client.get(api_url).send().await?; let response = client.get(api_url).send().await?;
if response.status().is_success() { if response.status().is_success() {
let jobs: Vec<Value> = response.json().await?; let jobs: Vec<Value> = response.json().await?;
for job in jobs { for job in jobs {

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.

View File

@@ -2,102 +2,56 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons - Nexrender Job Manager</title> <title>AE Anons — Управление заданиями</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/style.css"> <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> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- Header -->
<div class="header"> <div class="header">
<div class="header-left"> <div class="logo-area">
<div class="logo-container"> <img src="/assets/logo.png" alt="AE Anons Logo" class="logo">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo" <h1>AE Anons</h1>
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
<i class="fas fa-bolt"></i>
</div>
</div>
<h1>AE Anons - Nexrender Job Manager</h1>
</div> </div>
<div class="header-controls"> <div class="theme-toggle">
<button class="theme-toggle" onclick="toggleTheme()"> <button id="themeToggleBtn" class="theme-toggle-btn" title="Сменить тему">
<i class="fas fa-circle-half-stroke" id="themeIcon"></i> <i class="fas fa-moon"></i>
<span id="themeText">Auto</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Action Bar --> <div class="controls">
<div class="action-bar"> <button id="generateBtn" class="btn btn-primary">
<button class="btn btn-primary" onclick="generateJobs()"> <i class="fas fa-sync-alt"></i> Generate
<i class="fas fa-play"></i> Generate New Jobs
</button> </button>
<button class="btn btn-warning" onclick="stopAllJobs()"> <button id="cleanupBtn" class="btn btn-secondary">
<i class="fas fa-stop"></i> Stop All Jobs <i class="fas fa-trash-alt"></i> Cleanup
</button> </button>
<button class="btn btn-danger" onclick="cleanupJobs()"> <button id="stopAllBtn" class="btn btn-danger">
<i class="fas fa-trash-alt"></i> Cleanup Finished <i class="fas fa-stop-circle"></i> Stop all
</button> </button>
<button class="btn btn-outline" onclick="refreshJobs()"> <span id="statusMsg" class="status-msg"></span>
<i class="fas fa-sync-alt"></i> Refresh
</button>
<div class="status-message" id="statusMessage">
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
<span>Ready</span>
</div>
</div> </div>
<!-- Stats Grid --> <div class="table-wrapper">
<div class="stats-grid" id="statsGrid"></div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
onkeyup="filterTable()">
</div>
<div class="auto-refresh-badge">
<i class="fas fa-clock"></i>
<span>Auto-refresh: 60s</span>
<span id="refreshCountdown">60</span>s
</div>
</div>
<!-- Table -->
<div class="table-container">
<table id="jobsTable"> <table id="jobsTable">
<thead> <thead>
<tr> <tr>
<th data-column="filename" onclick="sortTable('filename')"> <th data-sort="uid">UID <i class="fas fa-sort"></i></th>
Output File <i class="fas fa-sort"></i> <th data-sort="filename">Файл <i class="fas fa-sort"></i></th>
</th> <th data-sort="status">Статус <i class="fas fa-sort"></i></th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc"> <th>Превью</th>
State <i class="fas fa-sort-up"></i> <th>Апрув</th>
</th> <th data-sort="created_at">Создан <i class="fas fa-sort"></i></th>
<th data-column="created" onclick="sortTable('created')">
Created <i class="fas fa-sort"></i>
</th>
<th data-column="updated" onclick="sortTable('updated')">
Updated <i class="fas fa-sort"></i>
</th>
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
</tr> </tr>
</thead> </thead>
<tbody id="jobsTableBody"> <tbody id="jobsBody">
<tr> <tr>
<td colspan="5"> <td colspan="6" class="loading">Загрузка заданий...</td>
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -105,384 +59,243 @@
</div> </div>
<script> <script>
// State // --- Тема (dark/light) с автоопределением ОС и кнопкой переключения ---
let allJobs = []; (function initTheme() {
let filteredJobs = []; const STORAGE_KEY = 'ae_anons_theme';
let isLoading = false; const btn = document.getElementById('themeToggleBtn');
let autoRefreshTimer = null; const icon = btn.querySelector('i');
let countdownTimer = null;
let countdownValue = 60;
let currentTheme = 'auto';
// Sorting state function setTheme(theme) {
let currentSort = { column: 'state', direction: 'asc' }; document.documentElement.setAttribute('data-theme', theme);
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 }; localStorage.setItem(STORAGE_KEY, theme);
if (theme === 'dark') {
// Theme handling icon.classList.remove('fa-sun');
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); icon.classList.add('fa-moon');
function getOSPreference() {
return darkModeMediaQuery.matches ? 'dark' : 'light';
}
function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
updateThemeButton(theme);
}
function updateThemeButton(theme) {
const icon = document.getElementById('themeIcon');
const text = document.getElementById('themeText');
if (theme === 'auto') {
icon.className = 'fas fa-circle-half-stroke';
text.textContent = 'Auto';
} else if (theme === 'dark') {
icon.className = 'fas fa-moon';
text.textContent = 'Dark';
} else {
icon.className = 'fas fa-sun';
text.textContent = 'Light';
}
}
function toggleTheme() {
if (currentTheme === 'auto') {
currentTheme = 'light';
} else if (currentTheme === 'light') {
currentTheme = 'dark';
} else {
currentTheme = 'auto';
}
localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme);
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'auto';
currentTheme = savedTheme;
applyTheme(currentTheme);
darkModeMediaQuery.addEventListener('change', (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
// Sorting
function sortTable(column) {
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
updateSortIcons();
sortAndRender();
}
function updateSortIcons() {
document.querySelectorAll('th').forEach(th => {
const column = th.dataset.column;
th.classList.remove('sorted-asc', 'sorted-desc');
const icon = th.querySelector('i');
if (column === currentSort.column) {
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
icon.className = currentSort.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
} else { } else {
icon.className = 'fas fa-sort'; 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 sortJobs(jobs) { function handleWsEvent(event) {
const { column, direction } = currentSort; switch (event.type) {
const multiplier = direction === 'asc' ? 1 : -1; case 'JobUpdated':
updateJobInList(event.job);
return [...jobs].sort((a, b) => { break;
let aVal, bVal; case 'GenerationStarted':
showStatus('Генерация заданий началась...', 'info');
switch (column) { break;
case 'filename': case 'GenerationFinished':
aVal = a.outfile_name || ''; showStatus(`Генерация завершена: создано ${event.count} заданий`, 'success');
bVal = b.outfile_name || ''; loadJobs();
return multiplier * aVal.localeCompare(bVal); break;
case 'Error':
case 'state': showStatus(`Ошибка: ${event.message}`, 'error');
aVal = stateOrder[a.state] || 999; break;
bVal = stateOrder[b.state] || 999; default:
return multiplier * (aVal - bVal); console.log('Unknown event:', event);
case 'created':
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'updated':
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'uid':
aVal = a.uid || '';
bVal = b.uid || '';
return multiplier * aVal.localeCompare(bVal);
default:
return 0;
}
});
}
function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
filteredJobs : allJobs;
const sorted = sortJobs(jobsToRender);
renderJobs(sorted);
}
// Data fetching
async function refreshJobs() {
if (isLoading) return;
isLoading = true;
try {
setStatus('loading', 'Loading jobs...');
const response = await fetch('/api/jobs');
allJobs = await response.json();
filteredJobs = [];
document.getElementById('filterInput').value = '';
sortAndRender();
updateStats();
setStatus('success', `Loaded ${allJobs.length} jobs`);
} catch (err) {
console.error(err);
setStatus('error', 'Failed to load jobs');
} finally {
isLoading = false;
} }
} }
function renderJobs(jobs) { function updateJobInList(job) {
const tbody = document.getElementById('jobsTableBody'); const idx = currentJobs.findIndex(j => j.uid === job.uid);
if (idx !== -1) {
currentJobs[idx] = job;
} else {
currentJobs.unshift(job);
}
renderJobsTable();
}
if (jobs.length === 0) { async function loadJobs() {
tbody.innerHTML = ` try {
<tr> const response = await fetch('/api/jobs');
<td colspan="5"> if (!response.ok) throw new Error(`HTTP ${response.status}`);
<div class="empty-state"> currentJobs = await response.json();
<i class="fas fa-inbox"></i> renderJobsTable();
<p>No jobs found</p> } catch (err) {
</div> console.error('Failed to load jobs:', err);
</td> document.getElementById('jobsBody').innerHTML = '<tr><td colspan="6">Ошибка загрузки заданий</td></tr>';
</tr> }
`; }
function renderJobsTable() {
const tbody = document.getElementById('jobsBody');
if (!currentJobs.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет заданий</td></tr>';
return; return;
} }
tbody.innerHTML = currentJobs.map(job => `
tbody.innerHTML = jobs.map(job => { <tr data-uid="${job.uid}">
const stateClass = getStateClass(job.state); <td class="uid">${escapeHtml(job.uid)}</td>
const created = formatDateTime(job.created_at); <td class="filename">${escapeHtml(job.filename)}</td>
const updated = formatDateTime(job.updated_at); <td class="status">${escapeHtml(job.status)}</td>
<td class="preview">
return ` <a href="/api/video/${job.uid}" target="_blank" title="Просмотр видео">
<tr> <i class="fas fa-video"></i>
<td> </a>
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div> </td>
</td> <td class="approve">
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td> <button class="approve-btn" data-uid="${job.uid}" ${job.approved ? 'disabled' : ''}>
<td class="datetime">${created}</td> ${job.approved ? '✅' : '✅'}
<td class="datetime">${updated}</td> </button>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td> </td>
</tr> <td class="created">${formatDate(job.created_at)}</td>
`; </tr>
}).join(''); `).join('');
// привязать обработчики апрува
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const uid = btn.getAttribute('data-uid');
approveJob(uid, btn);
});
});
} }
function escapeHtml(text) { async function approveJob(uid, btn) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStateClass(state) {
const classes = {
'finished': 'badge-finished',
'started': 'badge-started',
'processing': 'badge-processing',
'queued': 'badge-queued',
'error': 'badge-error',
'pending': 'badge-pending'
};
return classes[state] || 'badge-pending';
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
try { try {
const date = new Date(dateStr); const response = await fetch(`/api/approve/${uid}`, { method: 'POST' });
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const result = await response.json();
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }); if (response.ok) {
return `${time}, ${dayMonth}`; showStatus(`Файл скопирован: ${result.destination || 'approved'}`, 'success');
} catch { btn.disabled = true;
return dateStr; // обновить задание в списке
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');
} }
} }
function updateStats() {
const stats = {
total: allJobs.length,
finished: 0,
started: 0,
queued: 0,
error: 0,
other: 0
};
allJobs.forEach(job => {
switch (job.state) {
case 'finished': stats.finished++; break;
case 'started':
case 'processing': stats.started++; break;
case 'queued': stats.queued++; break;
case 'error': stats.error++; break;
default: stats.other++; break;
}
});
const grid = document.getElementById('statsGrid');
grid.innerHTML = `
<div class="stat-card">
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
<div class="value">${stats.total}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-check-circle"></i> Completed</h3>
<div class="value">${stats.finished}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-play-circle"></i> Active</h3>
<div class="value">${stats.started + stats.queued}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-exclamation-circle"></i> Errors</h3>
<div class="value">${stats.error}</div>
</div>
`;
}
function filterTable() {
const filter = document.getElementById('filterInput').value.toLowerCase();
filteredJobs = allJobs.filter(job =>
job.outfile_name.toLowerCase().includes(filter) ||
job.uid.toLowerCase().includes(filter)
);
sortAndRender();
}
// Actions
async function generateJobs() { async function generateJobs() {
setStatus('loading', 'Generating jobs...'); const btn = document.getElementById('generateBtn');
btn.disabled = true;
showStatus('Запуск генерации...', 'info');
try { try {
const response = await fetch('/api/generate', { method: 'POST' }); const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) { if (response.ok) {
setStatus('success', 'Job generation started'); showStatus('Генерация запущена', 'success');
setTimeout(() => refreshJobs(), 5000);
} else { } else {
const text = await response.text(); const text = await response.text();
setStatus('error', `Error: ${text}`); showStatus(`Ошибка: ${text}`, 'error');
} }
} catch (err) { } catch (err) {
setStatus('error', `Error: ${err}`); showStatus(`Ошибка: ${err.message}`, 'error');
} } finally {
} setTimeout(() => { btn.disabled = false; }, 2000);
async function stopAllJobs() {
if (!confirm('Are you sure you want to stop all active jobs?')) return;
setStatus('loading', 'Stopping all jobs...');
try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
if (response.ok) {
setStatus('success', 'All jobs stopped');
refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
} }
} }
async function cleanupJobs() { async function cleanupJobs() {
setStatus('loading', 'Cleaning up finished jobs...'); showStatus('Очистка завершённых заданий...', 'info');
try { try {
const response = await fetch('/api/cleanup', { method: 'POST' }); const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) { if (response.ok) {
setStatus('success', 'Cleanup completed'); showStatus('Очистка выполнена', 'success');
await refreshJobs(); loadJobs();
} else { } else {
const text = await response.text(); showStatus('Ошибка при очистке', 'error');
setStatus('error', `Error: ${text}`);
} }
} catch (err) { } catch (err) {
setStatus('error', `Error: ${err}`); showStatus(`Ошибка: ${err.message}`, 'error');
} }
} }
function setStatus(type, message) { async function stopAllJobs() {
const el = document.getElementById('statusMessage'); if (!confirm('Остановить все активные задания?')) return;
const icons = { showStatus('Остановка заданий...', 'info');
loading: '<span class="spinner"></span>', try {
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>', const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>' const text = await response.text();
}; showStatus(text, 'success');
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`; loadJobs();
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
}
} }
// Auto-refresh function showStatus(msg, type) {
function startAutoRefresh() { const el = document.getElementById('statusMsg');
stopAutoRefresh(); el.textContent = msg;
el.className = `status-msg ${type}`;
autoRefreshTimer = setInterval(() => { setTimeout(() => {
refreshJobs(); if (el.textContent === msg) el.textContent = '';
resetCountdown(); }, 5000);
}, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
}, 1000);
} }
function stopAutoRefresh() { function formatDate(iso) {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (!iso) return '';
if (countdownTimer) clearInterval(countdownTimer); const d = new Date(iso);
return d.toLocaleString();
} }
function resetCountdown() { function escapeHtml(str) {
countdownValue = 60; if (!str) return '';
document.getElementById('refreshCountdown').textContent = countdownValue; return str.replace(/[&<>]/g, function (m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
} }
// Initialize // Инициализация
initTheme(); connectWebSocket();
refreshJobs(); loadJobs();
startAutoRefresh(); document.getElementById('generateBtn').addEventListener('click', generateJobs);
updateSortIcons(); document.getElementById('cleanupBtn').addEventListener('click', cleanupJobs);
document.getElementById('stopAllBtn').addEventListener('click', stopAllJobs);
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
</script> </script>
</body> </body>

View File

@@ -1,555 +1,273 @@
/* ========================================
AE Anons - Nexrender Job Manager Styles
======================================== */
/* CSS Variables - Light Theme (default) */
:root { :root {
--bg-primary: #ffffff; --bg-body: #f5f7fa;
--bg-secondary: #fafafa; --bg-container: #ffffff;
--bg-tertiary: #f3f0f7; --text-primary: #1e2a3a;
--text-primary: #1a1a1a; --text-secondary: #4a5568;
--text-secondary: #6b4f7c; --border-color: #e2e8f0;
--border-color: #e0d4e8; --table-header-bg: #f8fafc;
--accent-primary: #7c3aed; --btn-primary-bg: #3b82f6;
--accent-secondary: #c2410c; --btn-primary-hover: #2563eb;
--accent-success: #10b981; --btn-secondary-bg: #64748b;
--accent-warning: #ea580c; --btn-secondary-hover: #475569;
--accent-danger: #ef4444; --btn-danger-bg: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --btn-danger-hover: #dc2626;
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); --status-info: #3b82f6;
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); --status-success: #10b981;
--header-bg: #7c3aed; --status-error: #ef4444;
--header-text: #ffffff; --logo-brightness: 1;
} }
/* Dark Theme */
[data-theme="dark"] { [data-theme="dark"] {
--bg-primary: #1a1025; --bg-body: #0f172a;
--bg-secondary: #251a30; --bg-container: #1e293b;
--bg-tertiary: #352545; --text-primary: #f1f5f9;
--text-primary: #f0e6ff; --text-secondary: #cbd5e1;
--text-secondary: #c4a6d9; --border-color: #334155;
--border-color: #4a3560; --table-header-bg: #0f172a;
--accent-primary: #a78bfa; --btn-primary-bg: #2563eb;
--accent-secondary: #f59e0b; --btn-primary-hover: #3b82f6;
--accent-success: #34d399; --btn-secondary-bg: #475569;
--accent-warning: #fbbf24; --btn-secondary-hover: #64748b;
--accent-danger: #f87171; --btn-danger-bg: #dc2626;
--header-bg: #a78bfa; --btn-danger-hover: #ef4444;
--header-text: #1a1025; --logo-brightness: 0.9;
} }
/* Reset & Base */
* { * {
margin: 0;
padding: 0;
box-sizing: border-box; box-sizing: border-box;
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0;
background: var(--bg-primary); padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; transition: background-color 0.3s, color 0.2s;
min-height: 100vh;
} }
.container { .container {
max-width: 100%; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 24px; background-color: var(--bg-container);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
} }
/* Header */
.header { .header {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 24px; align-items: center;
flex-wrap: wrap; padding: 1rem 2rem;
gap: 16px; border-bottom: 1px solid var(--border-color);
} }
.header-left { .logo-area {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 1rem;
}
.logo-container {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--accent-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: var(--shadow-md);
} }
.logo { .logo {
width: 100%; width: 150px;
height: 100%; /* уменьшенный размер */
object-fit: cover; height: auto;
filter: brightness(var(--logo-brightness));
} }
.logo-placeholder { .logo-area h1 {
width: 100%; margin: 0;
height: 100%; font-size: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
.header h1 {
font-size: 28px;
font-weight: 600; font-weight: 600;
color: var(--accent-primary);
letter-spacing: -0.5px;
} }
.header-controls { .theme-toggle-btn {
display: flex; background: none;
align-items: center;
gap: 12px;
}
.theme-toggle {
background: var(--bg-secondary);
border: 2px solid var(--accent-primary);
border-radius: 24px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 14px;
box-shadow: var(--shadow-sm);
}
.theme-toggle:hover {
box-shadow: var(--shadow-md);
}
/* Action Bar */
.action-bar {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
border: 1px solid var(--border-color); 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);
} }
/* Buttons */
.btn { .btn {
padding: 10px 20px; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 0.9rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-weight: 500;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 0.5rem;
transition: all 0.2s ease;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
opacity: 0;
transition: opacity 0.2s;
}
.btn:hover::before {
opacity: 1;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
} }
.btn i { .btn i {
font-size: 14px; font-size: 1rem;
} }
/* Generate - Purple */
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%); background-color: var(--btn-primary-bg);
color: white; color: white;
border: none;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
}
[data-theme="dark"] .btn-primary {
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
color: #1a1025;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
} }
.btn-primary:hover { .btn-primary:hover {
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); background-color: var(--btn-primary-hover);
} }
/* Stop - Orange */ .btn-secondary {
.btn-warning { background-color: var(--btn-secondary-bg);
background: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
color: white; color: white;
border: none;
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
} }
[data-theme="dark"] .btn-warning { .btn-secondary:hover {
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%); background-color: var(--btn-secondary-hover);
color: #1a1025;
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
} }
.btn-warning:hover {
box-shadow: 0 4px 12px rgba(234, 88, 12, 0.4);
}
/* Cleanup - Plum */
.btn-danger { .btn-danger {
background: linear-gradient(135deg, #9d174d 0%, #be185d 100%); background-color: var(--btn-danger-bg);
color: white; color: white;
border: none;
box-shadow: 0 2px 8px rgba(157, 23, 77, 0.3);
}
[data-theme="dark"] .btn-danger {
background: linear-gradient(135deg, #be185d 0%, #db2777 100%);
color: #f0e6ff;
box-shadow: 0 2px 8px rgba(190, 24, 93, 0.4);
} }
.btn-danger:hover { .btn-danger:hover {
box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4); background-color: var(--btn-danger-hover);
} }
/* Refresh - Outline */ .btn:active {
.btn-outline { transform: scale(0.97);
background: transparent;
border: 2px solid #7c3aed;
color: #7c3aed;
box-shadow: none;
} }
[data-theme="dark"] .btn-outline { .status-msg {
border-color: #a78bfa;
color: #a78bfa;
}
.btn-outline:hover {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
[data-theme="dark"] .btn-outline:hover {
background: #a78bfa;
color: #1a1025;
border-color: #a78bfa;
}
/* Status Message */
.status-message {
margin-left: auto; margin-left: auto;
padding: 8px 16px; font-size: 0.85rem;
background: var(--bg-tertiary); padding: 0.25rem 0.75rem;
border-radius: 20px; border-radius: 20px;
font-size: 14px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
} }
/* Stats Cards */ .status-msg.info {
.stats-grid { background-color: var(--status-info);
display: grid; color: white;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
} }
.stat-card { .status-msg.success {
background: var(--bg-secondary); background-color: var(--status-success);
border-radius: 12px; color: white;
padding: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
} }
.stat-card h3 { .status-msg.error {
font-size: 14px; background-color: var(--status-error);
color: var(--text-secondary); color: white;
margin-bottom: 8px;
font-weight: 500;
} }
.stat-card .value { .table-wrapper {
font-size: 32px;
font-weight: 700;
color: var(--accent-primary);
}
/* Filter Bar */
.filter-bar {
margin-bottom: 20px;
display: flex;
gap: 12px;
align-items: center;
}
.search-wrapper {
flex: 1;
position: relative;
}
.search-wrapper i {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
}
.search-input {
width: 100%;
padding: 12px 16px 12px 44px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 24px;
color: var(--text-primary);
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.auto-refresh-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
border-radius: 20px;
font-size: 13px;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
/* Table */
.table-container {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: var(--shadow-lg);
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--border-color); padding: 0 2rem 2rem 2rem;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: auto; font-size: 0.9rem;
min-width: 800px; }
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
} }
th { th {
text-align: left; background-color: var(--table-header-bg);
padding: 16px;
background: var(--header-bg);
color: var(--header-text);
font-weight: 600; font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
position: relative;
}
th:hover {
opacity: 0.9;
} }
th i { th i {
margin-left: 8px; margin-left: 0.5rem;
font-size: 12px; opacity: 0.5;
opacity: 0.7;
}
th.sorted-asc i,
th.sorted-desc i {
opacity: 1;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
} }
tr:hover { tr:hover {
background: var(--bg-tertiary); background-color: rgba(0, 0, 0, 0.02);
} }
/* Job Filename */ [data-theme="dark"] tr:hover {
.job-filename { background-color: rgba(255, 255, 255, 0.05);
font-family: 'Monaco', 'Menlo', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
word-break: break-word;
white-space: normal;
max-width: 100%;
color: var(--text-primary);
} }
/* Badges */ .loading {
.badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
display: inline-block;
white-space: nowrap;
}
.badge-finished {
background: rgba(16, 185, 129, 0.15);
color: var(--accent-success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.badge-started,
.badge-processing {
background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.3);
}
.badge-queued {
background: rgba(234, 88, 12, 0.15);
color: var(--accent-warning);
border: 1px solid rgba(234, 88, 12, 0.3);
}
.badge-error {
background: rgba(239, 68, 68, 0.15);
color: var(--accent-danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-pending {
background: rgba(245, 158, 11, 0.15);
color: var(--accent-secondary);
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* Date & Time */
.datetime {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.uid {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
/* Empty State */
.empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 2rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.empty-state i { .approve-btn {
font-size: 48px; background: none;
margin-bottom: 16px; 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; opacity: 0.5;
color: var(--accent-primary); cursor: default;
} }
/* Loading Spinner */ .preview a {
.spinner { color: var(--text-primary);
width: 20px; text-decoration: none;
height: 20px; font-size: 1.2rem;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
} }
@keyframes spin { .preview a:hover {
to { opacity: 0.7;
transform: rotate(360deg);
}
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.container {
padding: 16px; .header,
.controls,
.table-wrapper {
padding-left: 1rem;
padding-right: 1rem;
} }
.header { .logo {
flex-direction: column; width: 100px;
align-items: flex-start;
} }
.header-left { .logo-area h1 {
flex-wrap: wrap; font-size: 1.2rem;
} }
.header h1 { th,
font-size: 20px; td {
} padding: 0.5rem;
.action-bar {
flex-direction: column;
align-items: stretch;
}
.status-message {
margin-left: 0;
} }
} }

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)
}

View File

@@ -1,110 +1,74 @@
use crate::config::Config; use crate::config::Config;
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet}; use crate::processor::{fetch_all_jobs, process_spreadsheet};
use crate::storage::{self, JobRecord};
use axum::{ use axum::{
extract::State, extract::{Path, State, WebSocketUpgrade},
http::StatusCode, http::{header::CONTENT_TYPE, StatusCode},
response::{Html, IntoResponse, Json}, response::{Html, IntoResponse, Json, Response},
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use serde::Serialize; 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::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::broadcast;
use tower_http::services::ServeDir; use tokio_util::io::ReaderStream;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use url::Url;
use chrono::Utc;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub config: Config, pub config: Config,
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>, pub db: SqlitePool,
pub ws_tx: broadcast::Sender<WsEvent>,
pub last_generation: Arc<tokio::sync::Mutex<Option<chrono::DateTime<chrono::Local>>>>,
} }
#[derive(Serialize)] #[derive(Serialize, Clone)]
pub struct JobInfo { #[serde(tag = "type")]
pub uid: String, pub enum WsEvent {
pub outfile_name: String, JobUpdated(JobRecord),
pub state: String, GenerationStarted,
pub created_at: Option<String>, GenerationFinished { count: usize },
pub updated_at: Option<String>, Error { message: String },
} }
impl JobInfo {
fn from_nexrender_json(job: &serde_json::Value) -> Option<Self> {
let uid = job.get("uid")?.as_str()?.to_string();
let state = job.get("state")?.as_str()?.to_string();
// Извлекаем имя выходного файла из postrender actions
let outfile_name = job
.get("actions")
.and_then(|a| a.get("postrender"))
.and_then(|p| p.as_array())
.and_then(|arr| {
// Ищем действие copy (в нём финальный путь)
arr.iter()
.find_map(|action| {
// Проверяем, что это действие copy
action
.get("module")
.and_then(|m| m.as_str())
.filter(|&m| m == "@nexrender/action-copy")
.and_then(|_| {
// Извлекаем output из copy
action.get("output").and_then(|o| o.as_str())
})
})
// Если copy не найдено, пробуем encode
.or_else(|| {
arr.iter()
.find_map(|action| action.get("output").and_then(|o| o.as_str()))
})
})
.map(|path| {
// Извлекаем только имя файла из полного пути
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string()
})
.unwrap_or_else(|| {
// Если не удалось извлечь, используем UID
format!("job_{}", uid)
});
Some(JobInfo {
uid,
outfile_name,
state,
created_at: job
.get("createdAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
updated_at: job
.get("updatedAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
})
}
}
// web.rs - функция run_web_server
pub async fn run_web_server(config: Config) -> anyhow::Result<()> { pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
let web_port = config.web_port; // Берём порт из конфига let web_port = config.web_port;
let db = storage::init_db(&config.database_url).await?;
let (ws_tx, _) = broadcast::channel(100);
let state = AppState { let state = AppState {
config, config,
last_generation: Arc::new(Mutex::new(None)), 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() let app = Router::new()
.route("/", get(index_page)) .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/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs)) .route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs)) .route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status)) .route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs)) .route("/api/jobs/stop-all", post(stop_all_jobs))
.nest_service("/static", ServeDir::new("src/static")) .route("/api/video/{uid}", get(serve_video))
.nest_service("/assets", ServeDir::new("assets")) .route("/api/approve/{uid}", post(approve_job))
.route("/ws", get(ws_handler))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
@@ -116,20 +80,89 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
Ok(()) 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> { async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html")) Html(include_str!("static/index.html"))
} }
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> { async fn style_css() -> impl IntoResponse {
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url) (
[(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 .await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let jobs: Vec<JobInfo> = jobs_json
.iter()
.filter_map(JobInfo::from_nexrender_json)
.collect();
Ok(Json(jobs)) Ok(Json(jobs))
} }
@@ -147,34 +180,64 @@ async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
*last_gen = Some(chrono::Local::now()); *last_gen = Some(chrono::Local::now());
drop(last_gen); drop(last_gen);
let config = state.config.clone(); let state_clone = state.clone();
let last_gen_clone = state.last_generation.clone();
tokio::spawn(async move { tokio::spawn(async move {
match process_spreadsheet(&config).await { 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) => { Ok(submitted) => {
log::info!("Generation completed, {} jobs submitted", submitted.len()); log::info!("Generation completed, {} jobs submitted", submitted.len());
let _ = state_clone.ws_tx.send(WsEvent::GenerationFinished {
count: submitted.len(),
});
} }
Err(e) => { Err(e) => {
log::error!("Generation failed: {}", e); log::error!("Generation failed: {}", e);
let _ = state_clone.ws_tx.send(WsEvent::Error {
message: e.to_string(),
});
} }
} }
*last_gen_clone.lock().await = None; *state_clone.last_generation.lock().await = None;
}); });
Ok((StatusCode::ACCEPTED, "Job generation started")) Ok((StatusCode::ACCEPTED, "Job generation started"))
} }
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> { async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
cleanup_finished_jobs(&state.config.nexrender_api_url) let client = reqwest::Client::new();
let api_url = &state.config.nexrender_api_url;
let jobs = fetch_all_jobs(api_url)
.await .await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::OK, "Cleanup completed")) 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> { async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let last_gen = *state.last_generation.lock().await; let last_gen = *state.last_generation.lock().await;
let status = serde_json::json!({ let status = json!({
"last_generation": last_gen.map(|dt| dt.to_rfc3339()), "last_generation": last_gen.map(|dt| dt.to_rfc3339()),
"nexrender_api": state.config.nexrender_api_url, "nexrender_api": state.config.nexrender_api_url,
}); });
@@ -187,17 +250,15 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
let client = Client::new(); let client = Client::new();
let api_url = &state.config.nexrender_api_url; let api_url = &state.config.nexrender_api_url;
// Получаем все задания
let jobs = fetch_all_jobs(api_url) let jobs = fetch_all_jobs(api_url)
.await .await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut stopped = 0; let mut stopped = 0;
for job in jobs { for job in jobs {
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or(""); let state_str = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state == "queued" || state == "started" || state == "processing" { if state_str == "queued" || state_str == "started" || state_str == "processing" {
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) { if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
// Отправляем DELETE запрос для остановки задания
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await; let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
stopped += 1; stopped += 1;
log::info!("Stopped job: {}", uid); log::info!("Stopped job: {}", uid);
@@ -209,11 +270,220 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped))) Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
} }
// Error handling 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); struct AppError(StatusCode, String);
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> Response {
(self.0, self.1).into_response() (self.0, self.1).into_response()
} }
} }