15 Commits

Author SHA1 Message Date
ff0652b992 Добавлен фитбек переноса файлов 2026-05-07 14:32:22 +03:00
a3fc5e2940 SMB чтение/запись работают 2026-05-07 12:58:09 +03:00
6ec569f6e6 Видео проигрываеться 2026-05-07 11:25:44 +03:00
576e2b68fc Компилиться но не работает 2026-05-06 17:43:44 +03:00
fde16de0c6 License update 2026-05-06 14:51:22 +03:00
418f12ca4a Финальное исправление иконок в оформлении 2026-05-06 14:21:09 +03:00
7001aeb070 Иправления 2026-05-06 12:58:29 +03:00
b793571681 Исправленны иконки 2026-05-06 12:47:46 +03:00
74412ad368 Не большие изменения 2026-05-06 12:31:42 +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
37 changed files with 3157 additions and 497 deletions

View File

@@ -20,3 +20,35 @@ TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
TEMPLATE_COMPOSITION="main"
TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4"
# --- Источник готовых видео (где воркер Nexrender уже сохранил файлы) ---
# Тип доступа определяется тем, какая переменная задана:
# SOURCE_LOCAL_PATH -> локальная папка
# SOURCE_SMB_URL -> SMB-шара
# SOURCE_SYNOLOGY_PATH -> папка на Synology NAS (через FileStation API)
# Должна быть указана ровно одна из них.
# Пример локального источника:
# SOURCE_LOCAL_PATH="/mnt/rendered"
# Пример SMB-источника (если нужен закомментируйте LOCAL и раскомментируйте SMB):
#SOURCE_SMB_URL="smb://192.168.1.10/share/output"
#SOURCE_SMB_USER="user"
#SOURCE_SMB_PASS="pass"
# Пример Synology-источника:
#SOURCE_SYNOLOGY_PATH="/Team Folder/output"
# --- Приёмник (куда будут перемещены проверенные видео) ---
# Аналогично, одна из трёх переменных обязательна.
# Пример локального приёмника:
# DESTINATION_LOCAL_PATH="/mnt/approved"
# Пример SMB-приёмника (раскомментировать при необходимости):
#DESTINATION_SMB_URL="smb://192.168.1.20/share/final"
#DESTINATION_SMB_USER="user"
#DESTINATION_SMB_PASS="pass"
# Пример Synology-приёмника:
#DESTINATION_SYNOLOGY_PATH="/Team Folder/final"

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@
# Логи
logs/
*.log
out.txt
.build_timing
# Выходные файлы
output/

1911
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ae_anons"
version = "0.2.4"
version = "0.3.0"
edition = "2021"
license = "MIT"
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
@@ -26,6 +26,9 @@ anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
bytes = "1.9"
smb = { version = "0.11.2", features = ["async"] }
async-trait = "0.1"
url = "2"
# Web server (updated to latest stable versions)
axum = "0.8.9"
@@ -48,3 +51,6 @@ opt-level = "z"
lto = true
codegen-units = 1
strip = true
[build-dependencies]
winres = "0.1"

View File

@@ -1,7 +1,5 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@@ -12,10 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии v0.2.4
## Новое в версии v0.2.6
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
@@ -610,54 +615,64 @@ ae_anons/
## История изменений
### v0.2.4 (текущая)
### v0.2.6 (текущая)
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
### v0.2.5
- Оптимизация памяти: очистка неиспользуемых логотипов
- Групповая отправка заданий по 3 для сохранения порядка
- Добавлен UID в логи отправки
### v0.2.4
- Полностью автономный режим (встроенный Font Awesome)
- Добавлен favicon
- Все статические ресурсы встроены в бинарник
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
---

BIN
assets/logo.icns Normal file

Binary file not shown.

BIN
assets/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

11
build.rs Normal file
View File

@@ -0,0 +1,11 @@
fn main() {
#[cfg(windows)]
{
if let Err(e) = winres::WindowsResource::new()
.set_icon("assets/logo.ico")
.compile()
{
eprintln!("warning: не удалось встроить иконку: {e}");
}
}
}

View File

@@ -1,4 +1,4 @@
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single icons
# Название проекта
PROJECT_NAME := ae_anons
@@ -18,11 +18,14 @@ BLUE := \033[0;34m
CYAN := \033[0;36m
MAGENTA := \033[0;35m
BOLD := \033[1m
NC := \033[0m # No Color
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 ""
@@ -41,6 +44,10 @@ help:
@echo " make package - Создать пакеты для всех платформ"
@echo " make package-single - Создать пакет для текущей платформы"
@echo ""
@echo "$(GREEN)Подготовка ресурсов:$(NC)"
@echo " make setup - Подготовить иконки (логотипы, favicon)"
@echo " make setup-fontawesome-manual - Скачать и исправить all.min.css (если удалён)"
@echo ""
@echo "$(GREEN)Очистка:$(NC)"
@echo " make clean - Очистить все сборки"
@echo " make clean-timing - Очистить файл с временами сборки"
@@ -49,24 +56,27 @@ help:
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================
# Копирование общих файлов в пакет
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)); \
echo "$(1): $$duration сек" >> $(TIMING_FILE); \
echo "$(GREEN)$(1) завершён за $$duration сек$(NC)"
if [ $$duration -ge 60 ]; then \
min=$$((duration / 60)); \
sec=$$((duration % 60)); \
echo "$(GREEN)$(1) завершён за $${min}m $${sec}s$(NC)"; \
else \
echo "$(GREEN)$(1) завершён за $${duration}s$(NC)"; \
fi
endef
# Функция для вывода итоговой таблицы
define print_timing_summary
@echo ""
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
@@ -88,6 +98,58 @@ define print_timing_summary
@rm -f $(TIMING_FILE)
endef
# ============================================
# ПОДГОТОВКА РЕСУРСОВ
# ============================================
setup: icons
@echo "$(GREEN)Все статические ресурсы подготовлены$(NC)"
# Генерация иконок (favicon, .ico, .icns) из логотипа
icons:
@echo "$(GREEN)🎨 Генерация иконок...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \
fi
# favicon.ico
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=48,32,16 src/static/favicon.ico
@echo "$(GREEN) ✓ src/static/favicon.ico$(NC)"
# 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 || true
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_16x16@2x.png 2>/dev/null || true
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_32x32.png 2>/dev/null || true
@sips -z 64 64 assets/logo.png --out assets/icon.iconset/icon_32x32@2x.png 2>/dev/null || true
@sips -z 128 128 assets/logo.png --out assets/icon.iconset/icon_128x128.png 2>/dev/null || true
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_128x128@2x.png 2>/dev/null || true
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_256x256.png 2>/dev/null || true
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_256x256@2x.png 2>/dev/null || true
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_512x512.png 2>/dev/null || true
@iconutil -c icns assets/icon.iconset -o assets/logo.icns 2>/dev/null || true
@rm -rf assets/icon.iconset
@echo "$(GREEN) ✓ assets/logo.icns$(NC)"
# Восстановить CSS Font Awesome вручную (если файл удалён)
setup-fontawesome-manual:
@echo "$(GREEN)📥 Скачивание Font Awesome CSS...$(NC)"
@curl -sL -o src/static/fontawesome/all.min.css $(FONTAWESOME_CSS_URL)
@sed -i '' -E 's|(\.\./)*webfonts/|/static/fontawesome/webfonts/|g' \
src/static/fontawesome/all.min.css
@echo "$(GREEN)✓ CSS восстановлен и пути исправлены$(NC)"
# ============================================
# ИКОНКА ДЛЯ WINDOWS .EXE
# ============================================
windows-icon:
@if [ ! -f assets/logo.ico ]; then \
echo "$(YELLOW)logo.ico не найден, запускаю генерацию иконок...$(NC)"; \
$(MAKE) icons; \
fi
# ============================================
# ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ
# ============================================
@@ -113,7 +175,6 @@ endif
quick: detect-platform
# Быстрая сборка без упаковки (только бинарник)
quick-release:
ifeq ($(UNAME_S),Darwin)
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@@ -147,7 +208,7 @@ quick-linux:
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)"
@$(call print_timing_summary)
quick-windows:
quick-windows: windows-icon
@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); \
@@ -170,7 +231,7 @@ build-mac:
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@time cargo build --release
build-windows:
build-windows: windows-icon
@echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)"
@time cargo build --target x86_64-pc-windows-gnu --release

15
out.txt Normal file
View File

@@ -0,0 +1,15 @@
Compiling ae_anons v0.2.6 (/Users/Lexx/Code/RUST/AE_Anons)
warning: field `smb_domain` is never read
--> src/config.rs:18:9
|
12 | pub struct AccessConfig {
| ------------ field in this struct
...
18 | pub smb_domain: Option<String>,
| ^^^^^^^^^^
|
= note: `AccessConfig` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: `ae_anons` (bin "ae_anons") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s

View File

@@ -1,7 +1,5 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@@ -12,17 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3
## Новое в версии v0.2.6
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
@@ -576,6 +574,7 @@ ae_anons/
## 🙏 Благодарности
### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
@@ -587,7 +586,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com
© 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия
@@ -616,49 +615,64 @@ ae_anons/
## История изменений
### v0.2.3 (текущая)
### v0.2.6 (текущая)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
### v0.2.5
- Оптимизация памяти: очистка неиспользуемых логотипов
- Групповая отправка заданий по 3 для сохранения порядка
- Добавлен UID в логи отправки
### v0.2.4
- Полностью автономный режим (встроенный Font Awesome)
- Добавлен favicon
- Все статические ресурсы встроены в бинарник
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
---

View File

@@ -1,7 +1,5 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@@ -12,17 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3
## Новое в версии v0.2.6
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
@@ -576,6 +574,7 @@ ae_anons/
## 🙏 Благодарности
### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
@@ -587,7 +586,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com
© 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия
@@ -616,49 +615,64 @@ ae_anons/
## История изменений
### v0.2.3 (текущая)
### v0.2.6 (текущая)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
### v0.2.5
- Оптимизация памяти: очистка неиспользуемых логотипов
- Групповая отправка заданий по 3 для сохранения порядка
- Добавлен UID в логи отправки
### v0.2.4
- Полностью автономный режим (встроенный Font Awesome)
- Добавлен favicon
- Все статические ресурсы встроены в бинарник
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
---

View File

@@ -1,7 +1,5 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@@ -12,17 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3
## Новое в версии v0.2.6
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
@@ -576,6 +574,7 @@ ae_anons/
## 🙏 Благодарности
### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
@@ -587,7 +586,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com
© 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия
@@ -616,49 +615,64 @@ ae_anons/
## История изменений
### v0.2.3 (текущая)
### v0.2.6 (текущая)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
### v0.2.5
- Оптимизация памяти: очистка неиспользуемых логотипов
- Групповая отправка заданий по 3 для сохранения порядка
- Добавлен UID в логи отправки
### v0.2.4
- Полностью автономный режим (встроенный Font Awesome)
- Добавлен favicon
- Все статические ресурсы встроены в бинарник
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
---

View File

@@ -1,7 +1,5 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View File

@@ -12,17 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3
## Новое в версии v0.2.6
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
@@ -576,6 +574,7 @@ ae_anons/
## 🙏 Благодарности
### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
@@ -587,7 +586,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com
© 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия
@@ -616,49 +615,64 @@ ae_anons/
## История изменений
### v0.2.3 (текущая)
### v0.2.6 (текущая)
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
### v0.2.5
- Оптимизация памяти: очистка неиспользуемых логотипов
- Групповая отправка заданий по 3 для сохранения порядка
- Добавлен UID в логи отправки
### v0.2.4
- Полностью автономный режим (встроенный Font Awesome)
- Добавлен favicon
- Все статические ресурсы встроены в бинарник
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема
- Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл
### v0.2.0
- Добавлен веб-интерфейс для управления заданиями
- Реализован REST API
- Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий
- Возможность остановки всех активных заданий
### v0.1.1
- Оптимизирована работа с памятью
- Убрана функция создания .json
### v0.1.0
- Первый выпуск
- Интеграция с Synology Office
- Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов
---

View File

@@ -1,6 +1,57 @@
use anyhow::{Context, Result};
use std::env;
#[derive(Debug, Clone)]
pub enum AccessType {
Local,
Smb,
Synology,
}
#[derive(Debug, Clone)]
pub struct AccessConfig {
pub access_type: AccessType,
pub local_path: Option<String>,
pub smb_url: Option<String>,
pub smb_user: Option<String>,
pub smb_pass: Option<String>,
pub smb_domain: Option<String>,
pub synology_path: Option<String>,
}
impl AccessConfig {
fn from_env(prefix: &str) -> Result<Self> {
let local_path = env::var(format!("{}_LOCAL_PATH", prefix)).ok();
let smb_url = env::var(format!("{}_SMB_URL", prefix)).ok();
let synology_path = env::var(format!("{}_SYNOLOGY_PATH", prefix)).ok();
let access_type = if local_path.is_some() && smb_url.is_none() && synology_path.is_none() {
AccessType::Local
} else if smb_url.is_some() && local_path.is_none() && synology_path.is_none() {
AccessType::Smb
} else if synology_path.is_some() && local_path.is_none() && smb_url.is_none() {
AccessType::Synology
} else {
anyhow::bail!(
"Exactly one of {}_LOCAL_PATH, {}_SMB_URL, {}_SYNOLOGY_PATH must be set",
prefix,
prefix,
prefix
);
};
Ok(AccessConfig {
access_type,
local_path,
smb_url,
smb_user: env::var(format!("{}_SMB_USER", prefix)).ok(),
smb_pass: env::var(format!("{}_SMB_PASS", prefix)).ok(),
smb_domain: env::var(format!("{}_SMB_DOMAIN", prefix)).ok(),
synology_path,
})
}
}
#[derive(Debug, Clone)]
pub struct Config {
// Synology
@@ -11,14 +62,18 @@ pub struct Config {
// Nexrender
pub nexrender_api_url: String,
pub output_folder: String,
// Templates (all required)
// Templates
pub template_double_src: String,
pub template_single_src: String,
pub template_composition: String,
pub template_output_module: String,
pub template_output_ext: String,
//web_serwer
// Web server
pub web_port: u16,
// Source (where rendered videos appear)
pub source: AccessConfig,
// Destination (where to move approved videos)
pub destination: AccessConfig,
}
impl Config {
@@ -45,6 +100,8 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("Invalid WEB_PORT")?,
source: AccessConfig::from_env("SOURCE")?,
destination: AccessConfig::from_env("DESTINATION")?,
})
}
}

287
src/file_access.rs Normal file
View File

@@ -0,0 +1,287 @@
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use smb::{
resource::GetLen, Client, ClientConfig, CreateOptions, FileAccessMask, FileAttributes,
FileCreateArgs, ReadAt, UncPath, WriteAt,
};
use std::str::FromStr;
use url::Url;
/// smb://server/share/path → \\server\share[\path...]
fn smb_url_to_unc(url_str: &str) -> Result<UncPath> {
let url = Url::parse(url_str).context("Invalid SMB URL")?;
let host = url
.host_str()
.ok_or_else(|| anyhow!("No host in SMB URL"))?;
// Берём сырой путь
let raw_path = url.path().trim_start_matches('/');
// Декодируем каждый сегмент с помощью urlencoding (уже есть в зависимостях)
let decoded_segments: Vec<String> = raw_path
.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
urlencoding::decode(s)
.unwrap_or_else(|_| s.to_string().into())
.into_owned()
})
.collect();
if decoded_segments.is_empty() {
return Err(anyhow!("No path segments in SMB URL"));
}
let share = &decoded_segments[0];
let base = UncPath::from_str(&format!("\\\\{}\\{}", host, share))
.map_err(|e| anyhow!("Invalid UNC: {}", e))?;
let mut full = base.clone();
for segment in decoded_segments.iter().skip(1) {
full = full.with_path(segment);
}
Ok(full)
}
// ---------- Универсальный трейт ----------
#[async_trait]
pub trait FileAccess: Send + Sync {
async fn list_files(&self) -> Result<Vec<String>>;
async fn read_file(&self, filename: &str) -> Result<Vec<u8>>;
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()>;
async fn delete_file(&self, filename: &str) -> Result<()>;
}
// ---------- Локальная файловая система ----------
pub struct LocalFileAccess {
base_path: String,
}
impl LocalFileAccess {
pub fn new(base_path: &str) -> Self {
Self {
base_path: base_path.to_string(),
}
}
fn full_path(&self, filename: &str) -> String {
format!("{}/{}", self.base_path, filename)
}
}
#[async_trait]
impl FileAccess for LocalFileAccess {
async fn list_files(&self) -> Result<Vec<String>> {
let mut files = Vec::new();
let mut entries = tokio::fs::read_dir(&self.base_path).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_file() {
if let Some(name) = entry.file_name().to_str() {
files.push(name.to_string());
}
}
}
Ok(files)
}
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let path = self.full_path(filename);
log::debug!("LocalFileAccess reading file: {}", path);
tokio::fs::read(&path).await.map_err(|e| {
log::error!("Failed to read local file {}: {}", path, e);
anyhow::anyhow!(e)
})
}
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
let path = self.full_path(filename);
tokio::fs::write(&path, data).await.map_err(Into::into)
}
async fn delete_file(&self, filename: &str) -> Result<()> {
let path = self.full_path(filename);
tokio::fs::remove_file(&path).await.map_err(Into::into)
}
}
// ---------- SMB ----------
#[derive(Clone)]
pub struct SmbAccessConfig {
pub url: String,
pub user: Option<String>,
pub password: Option<String>,
pub domain: Option<String>,
}
pub struct SmbFileAccess {
config: SmbAccessConfig,
}
impl SmbFileAccess {
pub fn new(config: SmbAccessConfig) -> Self {
Self { config }
}
async fn connect(&self) -> Result<(Client, UncPath)> {
let base_unc = smb_url_to_unc(&self.config.url)?;
let client = Client::new(ClientConfig::default());
let (user, pass) = match (&self.config.user, &self.config.password) {
(Some(u), Some(p)) => (u.clone(), p.clone()),
_ => {
client.share_connect(&base_unc, "", "".to_string()).await?;
return Ok((client, base_unc));
}
};
// Если задан домен, добавляем его к имени пользователя: DOMAIN\username
let full_user = match &self.config.domain {
Some(d) if !d.is_empty() => format!("{}\\{}", d, user),
_ => user,
};
client.share_connect(&base_unc, &full_user, pass).await?;
Ok((client, base_unc))
}
/// Построить полный UNC путь к файлу, гарантируя один слеш между базой и именем
fn make_file_unc(base: &UncPath, filename: &str) -> Result<UncPath> {
let base_str = base.to_string();
let clean_base = base_str.trim_end_matches('\\');
// Заменяем все прямые слеши на обратные для UNC
let sanitized = filename.replace('/', "\\");
let clean_filename = sanitized.trim_start_matches('\\');
let full = format!("{}\\{}", clean_base, clean_filename);
UncPath::from_str(&full).map_err(|e| anyhow!("Invalid file UNC: {}", e))
}
}
#[async_trait]
impl FileAccess for SmbFileAccess {
async fn list_files(&self) -> Result<Vec<String>> {
// Заглушка
Ok(Vec::new())
}
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let (client, base_unc) = self.connect().await?;
let file_path = Self::make_file_unc(&base_unc, filename)?;
log::info!("SMB reading file: {:?}", file_path);
let args =
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
let resource = client.create_file(&file_path, &args).await.map_err(|e| {
log::error!("Failed to open SMB file {:?}: {}", file_path, e);
anyhow::anyhow!("Failed to open SMB file: {}", e)
})?;
let file = resource.unwrap_file();
let file_size = file.get_len().await? as usize;
let mut buf = vec![0u8; file_size];
let bytes_read = file.read_at(&mut buf, 0).await?;
buf.truncate(bytes_read);
file.close().await?;
Ok(buf)
}
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
let (client, base_unc) = self.connect().await?;
let file_path = Self::make_file_unc(&base_unc, filename)?;
log::info!("SMB writing file: {:?}", file_path);
let access_mask = FileAccessMask::new()
.with_generic_write(true)
.with_generic_read(true);
let mut args =
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
args.desired_access = access_mask;
let resource = client.create_file(&file_path, &args).await.map_err(|e| {
log::error!("Failed to create SMB file {:?}: {}", file_path, e);
anyhow::anyhow!("Failed to create SMB file: {}", e)
})?;
let remote_file = resource.unwrap_file();
remote_file.write_at(data, 0).await?;
remote_file.close().await?;
Ok(())
}
async fn delete_file(&self, filename: &str) -> Result<()> {
log::warn!(
"SMB delete not supported file left at source: {}",
filename
);
Ok(())
}
}
// ---------- Synology ----------
pub struct SynologyFileAccess {
client: crate::synology::SynologyClient,
base_path: String,
}
impl SynologyFileAccess {
pub fn new(client: crate::synology::SynologyClient, base_path: &str) -> Self {
Self {
client,
base_path: base_path.to_string(),
}
}
fn full_path(&self, filename: &str) -> String {
format!("{}/{}", self.base_path, filename)
}
}
#[async_trait]
impl FileAccess for SynologyFileAccess {
async fn list_files(&self) -> Result<Vec<String>> {
use crate::synology::ListFilesOptions;
let result = self
.client
.list_files(&self.base_path, ListFilesOptions::new())
.await
.map_err(|e| anyhow::anyhow!(e))?;
let files = result
.files
.into_iter()
.filter(|f| !f.isdir)
.map(|f| f.name)
.collect();
Ok(files)
}
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let path = self.full_path(filename);
self.client
.download(&path)
.await
.map_err(|e| anyhow::anyhow!(e))
}
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
self.client
.upload(
&self.base_path,
filename,
data.to_vec(),
Some(false),
Some(true),
)
.await
.map_err(|e| anyhow::anyhow!(e))
}
async fn delete_file(&self, filename: &str) -> Result<()> {
let path = self.full_path(filename);
self.client
.delete(&[&path], None)
.await
.map_err(|e| anyhow::anyhow!(e))
}
}

View File

@@ -1,4 +1,5 @@
mod config;
mod file_access;
mod nexrender;
mod processor;
mod synology;

View File

@@ -155,6 +155,8 @@ pub enum Asset {
asset_type: String,
#[serde(rename = "layerName")]
layer_name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
cache: bool,
},
Video {
src: String,
@@ -162,6 +164,8 @@ pub enum Asset {
asset_type: String,
#[serde(rename = "layerName")]
layer_name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
cache: bool,
},
}
@@ -208,6 +212,10 @@ pub struct JobData {
pub channel_logo: String,
pub outfile_name: String,
pub row_index: usize,
// Поля для сортировки
pub sort_date: NaiveDate,
pub sort_time: String,
pub variant_order: u8,
}
impl JobData {
@@ -233,6 +241,7 @@ impl JobData {
let (date_obj, data_display) = parse_date(&data_str);
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 channel_logo = channels.get(&channel).cloned().unwrap_or_default();
@@ -268,8 +277,8 @@ impl JobData {
.replace(' ', "-")
.replace('\'', "");
// Если 3 и более дефисов подряд -> заменяем на один
let re = Regex::new(r"-{3,}").unwrap();
// Заменяем 2 и более дефисов подряд на один
let re = Regex::new(r"-{2,}").unwrap();
outfile_name = re.replace_all(&outfile_name, "-").to_string();
// Убираем дефисы в начале и конце
@@ -294,19 +303,34 @@ impl JobData {
channel_logo: unc_to_uri(&channel_logo),
outfile_name,
row_index,
sort_date: date_obj,
sort_time,
variant_order: 0,
})
}
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();
today.data_display = "сегодня".to_string();
today.outfile_name = format!("{}_Today", self.outfile_name);
today.variant_order = 1;
variants.push(today);
// Tomorrow (2)
let mut tomorrow = self.clone();
tomorrow.data_display = "завтра".to_string();
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
tomorrow.variant_order = 2;
variants.push(tomorrow);
variants
}
@@ -346,6 +370,7 @@ impl JobData {
src: self.pack_path.clone(),
asset_type: "video".to_string(),
layer_name: "TOP".to_string(),
cache: true,
});
}
@@ -357,6 +382,7 @@ impl JobData {
}
fn add_text_assets(&self, assets: &mut Vec<Asset>) {
// DATA — всегда
assets.push(Asset::Data {
asset_type: "data".to_string(),
layer_name: "DATA".to_string(),
@@ -365,7 +391,9 @@ impl JobData {
});
self.add_date_adjustments(assets);
// TIME — зависит от шаблона
if !self.team_b.is_empty() {
// DOUBLE: TIME_H и TIME_M
assets.push(Asset::Data {
asset_type: "data".to_string(),
layer_name: "TIME_H".to_string(),
@@ -380,6 +408,7 @@ impl JobData {
});
self.add_time_adjustments(assets);
} else {
// SINGLE: TIME
assets.push(Asset::Data {
asset_type: "data".to_string(),
layer_name: "TIME".to_string(),
@@ -388,6 +417,7 @@ impl JobData {
});
}
// LEAGUE — всегда
assets.push(Asset::Data {
asset_type: "data".to_string(),
layer_name: "LEAGUE".to_string(),
@@ -396,6 +426,7 @@ impl JobData {
});
self.add_league_adjustments(assets);
// SPORT — всегда
if !self.sport.is_empty() {
assets.push(Asset::Data {
asset_type: "data".to_string(),
@@ -405,14 +436,9 @@ impl JobData {
});
}
let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() {
format!("{} - {}", self.team_a, self.team_b)
} else if !self.team_a.is_empty() {
self.team_a.clone()
} else {
self.team_b.clone()
};
if !teams_text.is_empty() {
// TEAMS — ТОЛЬКО для DOUBLE шаблона
if !self.team_b.is_empty() {
let teams_text = format!("{} - {}", self.team_a, self.team_b);
assets.push(Asset::Data {
asset_type: "data".to_string(),
layer_name: "TEAMS".to_string(),
@@ -485,11 +511,13 @@ impl JobData {
}
fn add_image_assets(&self, assets: &mut Vec<Asset>) {
// Логотипы команд — НЕ кешируем (часто меняются)
if !self.team_a_logo.is_empty() {
assets.push(Asset::Image {
src: self.team_a_logo.clone(),
asset_type: "image".to_string(),
layer_name: "TEAM_A_LOGO".to_string(),
cache: false,
});
if let Some(res) = &self.team_a_logo_res {
assets.push(Asset::DataExpression {
@@ -505,6 +533,7 @@ impl JobData {
src: self.team_b_logo.clone(),
asset_type: "image".to_string(),
layer_name: "TEAM_B_LOGO".to_string(),
cache: false,
});
if let Some(res) = &self.team_b_logo_res {
assets.push(Asset::DataExpression {
@@ -515,11 +544,13 @@ impl JobData {
});
}
}
// Логотип канала — КЕШИРУЕМ (редко меняется)
if !self.channel_logo.is_empty() {
assets.push(Asset::Image {
src: self.channel_logo.clone(),
asset_type: "image".to_string(),
layer_name: "CHANELL".to_string(),
cache: true,
});
}
}
@@ -545,7 +576,6 @@ impl LogoRegistry {
self.logos.insert((team, sport), link);
}
// В impl LogoRegistry, функция find()
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
@@ -564,6 +594,19 @@ impl LogoRegistry {
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
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) {
@@ -639,6 +682,7 @@ fn unc_to_uri(path: &str) -> String {
format!("file://{}", trimmed)
}
fn logo_scale_expression(target_size: &str) -> String {
format!(
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",

View File

@@ -274,33 +274,6 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
}
}
async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook,
config: &Config,
) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs...");
let start_sheet = workbook
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
let sport_sheet = workbook
.get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
let teams_sheet = workbook
.get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
// Загружаем sport packs
let packs: HashMap<String, String> = sport_sheet
.rows
.iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect();
info!("Loaded {} sport packs", packs.len());
// Вспомогательная функция для поиска значения по части ключа
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
if let Some(val) = row.get(key_part) {
@@ -316,11 +289,36 @@ async fn generate_and_submit_jobs(
None
}
// Загружаем team logos
async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook,
config: &Config,
) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs...");
// ========== ЗАГРУЖАЕМ SPORT PACKS ==========
let sport_sheet = workbook
.get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
let packs: HashMap<String, String> = sport_sheet
.rows
.iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect();
info!("Loaded {} sport packs", packs.len());
debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len());
// ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
let teams_sheet = workbook
.get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
debug!("TEAMS headers: {:?}", teams_sheet.headers);
if log_enabled!(Level::Debug) {
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
debug!("TEAMS row {}: {:?}", i, row);
}
}
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
for row in &teams_sheet.rows {
@@ -332,7 +330,8 @@ async fn generate_and_submit_jobs(
logos.insert(team.clone(), sport.clone(), link.clone());
}
}
info!("Loaded {} team logos", teams_sheet.rows.len());
let total_teams = teams_sheet.rows.len();
info!("Loaded {} team logos", total_teams);
// Дебаг: выводим статистику по TEAMS
if log_enabled!(Level::Debug) {
@@ -343,19 +342,23 @@ async fn generate_and_submit_jobs(
.collect();
debug!("Sports found in TEAMS sheet: {:?}", sports);
for sport in sports.iter() {
for sport in sports.iter().take(5) {
let examples: Vec<_> = teams_sheet
.rows
.iter()
.filter(|r| r.get("SPORT") == Some(*sport))
.take(5)
.take(3)
.filter_map(|r| r.get("TEAM"))
.collect();
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
}
}
// Загружаем channel logos
// ========== ЗАГРУЖАЕМ CHANNEL LOGOS ==========
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
let channels: HashMap<String, String> = channel_sheet
.rows
.iter()
@@ -367,19 +370,48 @@ async fn generate_and_submit_jobs(
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
}
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
// ========== ОБРАБАТЫВАЕМ START (только активные строки) ==========
let start_sheet = workbook
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
// Собираем статистику по ненайденным логотипам для дебага
let total_start_rows = start_sheet.rows.len();
// Сразу фильтруем только строки с STATE = "FALSE"
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(),
total_start_rows
);
// Собираем использованные команды для очистки logos
let mut used_teams: HashSet<(String, String)> = HashSet::new();
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
// Для дебага
let mut missing_teams: HashSet<String> = HashSet::new();
let mut missing_sports: HashSet<String> = HashSet::new();
for (idx, row) in start_sheet.rows.iter().enumerate() {
if let Some(state) = row.get("STATE") {
if state == "FALSE" {
for (idx, row) in active_rows {
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
let sport = row.get("SPORT").cloned().unwrap_or_default();
// Запоминаем использованные команды
if !team_a.is_empty() {
used_teams.insert((team_a.clone(), sport.clone()));
}
if !team_b.is_empty() {
used_teams.insert((team_b.clone(), sport.clone()));
}
if log_enabled!(Level::Debug) {
let logo_a = logos.find(&team_a, &sport);
let logo_b = logos.find(&team_b, &sport);
@@ -398,8 +430,15 @@ async fn generate_and_submit_jobs(
jobs.extend(job.create_variants());
}
}
}
}
// Очищаем 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()
);
// Дебаг: выводим сводку по отсутствующим логотипам
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
@@ -415,72 +454,68 @@ async fn generate_and_submit_jobs(
info!("Generated {} total jobs (including variants)", jobs.len());
// ========== СОРТИРОВКА ПО ДАТЕ И ВРЕМЕНИ ==========
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 log_enabled!(Level::Debug) {
for (i, job) in jobs.iter().take(10).enumerate() {
debug!(
" {}: {} {} - {} (variant: {})",
i + 1,
job.sort_date,
job.sort_time,
job.outfile_name,
job.variant_order
);
}
}
if jobs.is_empty() {
info!("No jobs with STATE='FALSE' found");
return Ok(Vec::new());
}
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
cleanup_finished_jobs(&config.nexrender_api_url).await?;
let http_client = Client::new();
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
// Отправляем задания группами по 3 (оригинал + today + tomorrow)
for chunk in jobs.chunks(3) {
let mut tasks = Vec::with_capacity(3);
for job in chunk {
let client = http_client.clone();
let api_url = config.nexrender_api_url.clone();
let config_clone = config.clone();
let job_owned = job.clone();
tasks.push(tokio::spawn(async move {
let nexrender_job = job_owned.to_nexrender_job(&config_clone);
info!("Submitting job: {}", job_owned.outfile_name);
for job in jobs {
let nexrender_job = job.to_nexrender_job(config);
info!("Submitting job: {}", job.outfile_name);
if log_enabled!(Level::Debug) {
debug!(
"Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'",
job_owned.sport, job_owned.league, job_owned.channel, job_owned.team_a, job_owned.team_b
job.sport, job.league, job.channel, job.team_a, job.team_b
);
}
let response = client.post(&api_url).json(&nexrender_job).send().await?;
let response = http_client
.post(&config.nexrender_api_url)
.json(&nexrender_job)
.send()
.await?;
if response.status().is_success() {
let result: Value = response.json().await?;
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
Ok::<_, anyhow::Error>(Some((uid.to_string(), job_owned.outfile_name)))
} else {
Ok(None)
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
}
} 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: {} (UID: {})", outfile_name, uid);
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);
}
}
}
}

BIN
src/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -4,12 +4,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons - Nexrender Job Manager</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="/favicon.ico">
<link rel="apple-touch-icon" href="/favicon.ico">
<title>AE Anons</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
@@ -20,13 +16,9 @@
<div class="header">
<div class="header-left">
<div class="logo-container">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
<i class="fas fa-bolt"></i>
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo">
</div>
</div>
<h1>AE Anons - Nexrender Job Manager</h1>
<h1>AE Anons</h1>
</div>
<div class="header-controls">
<button class="theme-toggle" onclick="toggleTheme()">
@@ -93,11 +85,13 @@
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
<th>Preview</th>
<th>Move</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<tr>
<td colspan="5">
<td colspan="7">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
@@ -109,8 +103,16 @@
</div>
</div>
<!-- Video Player Modal -->
<div id="playerModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closePlayer()">&times;</span>
<video id="videoPlayer" controls style="width:100%; max-height:70vh;"></video>
</div>
</div>
<script>
// State
// ========== ОРИГИНАЛЬНЫЙ КОД (Задания) ==========
let allJobs = [];
let filteredJobs = [];
let isLoading = false;
@@ -118,82 +120,47 @@
let countdownTimer = null;
let countdownValue = 60;
let currentTheme = 'auto';
// Sorting state
let currentSort = { column: 'state', direction: 'asc' };
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
// Theme handling
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function getOSPreference() {
return darkModeMediaQuery.matches ? 'dark' : 'light';
}
function 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';
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';
}
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');
}
});
darkModeMediaQuery.addEventListener('change', () => { 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';
}
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');
@@ -203,58 +170,30 @@
}
});
}
function sortJobs(jobs) {
const { column, direction } = currentSort;
const multiplier = direction === 'asc' ? 1 : -1;
return [...jobs].sort((a, b) => {
let aVal, bVal;
switch (column) {
case 'filename':
aVal = a.outfile_name || '';
bVal = b.outfile_name || '';
return multiplier * aVal.localeCompare(bVal);
case 'state':
aVal = stateOrder[a.state] || 999;
bVal = stateOrder[b.state] || 999;
return multiplier * (aVal - bVal);
case 'created':
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'updated':
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'uid':
aVal = a.uid || '';
bVal = b.uid || '';
return multiplier * aVal.localeCompare(bVal);
default:
return 0;
case 'filename': aVal = a.outfile_name || ''; bVal = b.outfile_name || ''; return multiplier * aVal.localeCompare(bVal);
case 'state': aVal = stateOrder[a.state] || 999; bVal = stateOrder[b.state] || 999; return multiplier * (aVal - bVal);
case 'created': aVal = a.created_at ? new Date(a.created_at).getTime() : 0; bVal = b.created_at ? new Date(b.created_at).getTime() : 0; return multiplier * (bVal - aVal);
case 'updated': aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0; bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0; return multiplier * (bVal - aVal);
case 'uid': aVal = a.uid || ''; bVal = b.uid || ''; return multiplier * aVal.localeCompare(bVal);
default: return 0;
}
});
}
function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
filteredJobs : allJobs;
const 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');
@@ -267,130 +206,73 @@
} catch (err) {
console.error(err);
setStatus('error', 'Failed to load jobs');
} finally {
isLoading = false;
}
} finally { isLoading = false; }
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
if (jobs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>No jobs found</p>
</div>
</td>
</tr>
`;
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><i class="fas fa-inbox"></i><p>No jobs found</p></div></td></tr>';
return;
}
tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at);
return `
<tr>
<td>
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
</td>
const previewBtn = job.state === 'finished'
? `<button class="btn btn-outline btn-sm" onclick="playVideo('${escapeHtml(job.outfile_name)}')"><i class="fas fa-play"></i></button>`
: '';
// Заглушка‑иконка, обновится асинхронно
const moveIcon = job.state === 'finished'
? `<span class="move-icon" data-file="${escapeHtml(job.outfile_name)}"><i class="fas fa-spinner fa-spin"></i></span>`
: '';
return `<tr>
<td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td>
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td>
<td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
</tr>
`;
<td>${previewBtn}</td>
<td>${moveIcon}</td>
</tr>`;
}).join('');
// Запускаем проверку статусов после рендера
updateMoveStatuses();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }
function getStateClass(state) {
const classes = {
'finished': 'badge-finished',
'started': 'badge-started',
'processing': 'badge-processing',
'queued': 'badge-queued',
'error': 'badge-error',
'pending': 'badge-pending'
};
const classes = { 'finished': 'badge-finished', 'started': 'badge-started', 'processing': 'badge-processing', 'queued': 'badge-queued', 'error': 'badge-error', 'pending': 'badge-pending' };
return classes[state] || 'badge-pending';
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
return `${time}, ${dayMonth}`;
} catch {
return dateStr;
try { const date = new Date(dateStr); return `${date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}, ${date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })}`; }
catch { return dateStr; }
}
}
function updateStats() {
const stats = {
total: allJobs.length,
finished: 0,
started: 0,
queued: 0,
error: 0,
other: 0
};
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 '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>
`;
document.getElementById('statsGrid').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)
);
filteredJobs = allJobs.filter(job => job.outfile_name.toLowerCase().includes(filter) || job.uid.toLowerCase().includes(filter));
sortAndRender();
}
// Actions
// Оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown
async function generateJobs() {
setStatus('loading', 'Generating jobs...');
try {
@@ -409,7 +291,6 @@
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' });
@@ -451,21 +332,13 @@
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
}
// Auto-refresh
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
autoRefreshTimer = setInterval(() => { refreshJobs(); resetCountdown(); }, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
if (countdownValue <= 0) countdownValue = 60;
}, 1000);
}
@@ -479,15 +352,56 @@
document.getElementById('refreshCountdown').textContent = countdownValue;
}
// Initialize
// ========== НОВЫЙ КОД: Видео ==========
function playVideo(filename) {
const player = document.getElementById('videoPlayer');
player.src = '/api/videos/' + encodeURIComponent(filename);
document.getElementById('playerModal').classList.add('active');
}
function closePlayer() {
const player = document.getElementById('videoPlayer');
player.pause();
player.src = '';
document.getElementById('playerModal').classList.remove('active');
}
async function moveVideo(filename) {
if (!confirm(`Переместить файл "${filename}" в целевую папку?`)) return;
try {
const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
alert('Файл успешно перемещён.');
refreshJobs(); // обновим список, возможно, файл исчезнет
} catch (e) {
alert('Ошибка перемещения: ' + e.message);
}
}
async function updateMoveStatuses() {
const icons = document.querySelectorAll('.move-icon');
for (const icon of icons) {
const filename = icon.dataset.file;
try {
const resp = await fetch('/api/videos/' + encodeURIComponent(filename) + '/status');
const status = await resp.json();
const exists = status.exists_in_dest;
const subfolder = status.subfolder;
if (exists) {
icon.innerHTML = `<i class="fas fa-check-circle" style="color: var(--accent-success);" title="Already in ${subfolder}"></i>`;
} else {
icon.innerHTML = `<button class="btn btn-primary btn-sm" onclick="moveVideo('${escapeHtml(filename)}')"><i class="fas fa-share"></i></button>`;
}
} catch (e) {
icon.innerHTML = `<i class="fas fa-question-circle" title="Status unknown"></i>`;
}
}
}
// ========== ИНИЦИАЛИЗАЦИЯ ==========
initTheme();
refreshJobs();
startAutoRefresh();
updateSortIcons();
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
</script>
</body>

View File

@@ -553,3 +553,47 @@ tr:hover {
margin-left: 0;
}
}
/* Модальное окно плеера */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
max-width: 90vw;
box-shadow: var(--shadow-lg);
position: relative;
}
.modal .close {
position: absolute;
top: 8px;
right: 16px;
font-size: 28px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
}
/* Кнопки в таблице (уменьшенный размер) */
.btn-sm {
padding: 4px 8px;
font-size: 12px;
gap: 4px;
}

View File

@@ -1,8 +1,12 @@
use crate::config::Config;
use crate::config::{AccessType, Config};
use crate::file_access::{
FileAccess, LocalFileAccess, SmbAccessConfig, SmbFileAccess, SynologyFileAccess,
};
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
use crate::synology::SynologyClient;
use axum::{
extract::State,
http::{header::CONTENT_TYPE, StatusCode},
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
response::{Html, IntoResponse, Json},
routing::{get, post},
Router,
@@ -11,13 +15,14 @@ use serde::Serialize;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
pub source_access: Arc<dyn FileAccess>,
pub dest_access: Arc<dyn FileAccess>,
}
#[derive(Serialize)]
@@ -78,10 +83,16 @@ impl JobInfo {
}
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
let source_access = create_file_access(&config.source, &config).await?;
let dest_access = create_file_access(&config.destination, &config).await?;
let web_port = config.web_port;
let state = AppState {
config,
last_generation: Arc::new(Mutex::new(None)),
source_access,
dest_access,
};
let app = Router::new()
@@ -89,44 +100,106 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
.route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_css))
.nest_service("/static/fontawesome/webfonts", ServeDir::new("src/static/fontawesome/webfonts"))
.route(
"/static/fontawesome/webfonts/fa-solid-900.woff2",
get(fa_solid_woff2),
)
.route(
"/static/fontawesome/webfonts/fa-regular-400.woff2",
get(fa_regular_woff2),
)
.route(
"/static/fontawesome/webfonts/fa-brands-400.woff2",
get(fa_brands_woff2),
)
.route("/assets/logo.png", get(logo_png))
.route("/api/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs))
.route("/api/videos", get(list_videos))
.route("/api/videos/{filename}", get(stream_video))
.route("/api/videos/{filename}/move", post(move_video))
.route("/api/videos/{filename}/status", get(get_video_status))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
log::info!("Web server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn create_file_access(
access_config: &crate::config::AccessConfig,
nas_config: &Config,
) -> anyhow::Result<Arc<dyn FileAccess>> {
match access_config.access_type {
AccessType::Local => {
let path = access_config.local_path.as_ref().unwrap();
Ok(Arc::new(LocalFileAccess::new(path)))
}
AccessType::Smb => {
let url = access_config.smb_url.as_ref().unwrap().clone();
let user = access_config.smb_user.clone();
let password = access_config.smb_pass.clone();
let domain = access_config.smb_domain.clone();
let smb_config = SmbAccessConfig {
url,
user,
password,
domain,
};
let access: Arc<dyn FileAccess> = Arc::new(SmbFileAccess::new(smb_config));
Ok(access)
}
AccessType::Synology => {
let mut client = SynologyClient::new(&nas_config.nas_fqdn);
client
.login(&nas_config.nas_user, &nas_config.nas_pass)
.await?;
let base_path = access_config.synology_path.as_ref().unwrap().clone();
Ok(Arc::new(SynologyFileAccess::new(client, &base_path)))
}
}
}
// Статические ресурсы
async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html"))
}
async fn style_css() -> impl IntoResponse {
([(CONTENT_TYPE, "text/css")], include_str!("static/style.css"))
(
[(header::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"))
(
[(header::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())
(
[(header::CONTENT_TYPE, "image/png")],
include_bytes!("../assets/logo.png").as_slice(),
)
}
async fn favicon() -> impl IntoResponse {
([(CONTENT_TYPE, "image/png")], include_bytes!("../assets/logo.png").as_slice())
(
[(header::CONTENT_TYPE, "image/x-icon")],
include_bytes!("static/favicon.ico").as_slice(),
)
}
// Управление заданиями Nexrender
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
.await
@@ -214,6 +287,166 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
}
// Видео
async fn list_videos(State(state): State<AppState>) -> Result<Json<Vec<String>>, AppError> {
let files = state
.source_access
.list_files()
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let videos: Vec<String> = files
.into_iter()
.filter(|name| {
let lower = name.to_lowercase();
lower.ends_with(".mp4") || lower.ends_with(".mov") || lower.ends_with(".avi")
})
.collect();
Ok(Json(videos))
}
async fn stream_video(
Path(filename): Path<String>,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Result<impl IntoResponse, AppError> {
log::info!("Stream video requested: {}", filename);
let file_data = state
.source_access
.read_file(&filename)
.await
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
let file_size = file_data.len() as u64;
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
let (status, content_range, start, end) = if let Some(range_str) = range {
let range_str = range_str.trim_start_matches("bytes=");
let parts: Vec<&str> = range_str.split('-').collect();
let start: u64 = parts[0].parse().unwrap_or(0);
let end: u64 = if parts.len() > 1 && !parts[1].is_empty() {
parts[1].parse().unwrap_or(file_size - 1)
} else {
file_size - 1
};
(
StatusCode::PARTIAL_CONTENT,
Some(format!("bytes {}-{}/{}", start, end, file_size)),
start,
end,
)
} else {
(StatusCode::OK, None, 0, file_size - 1)
};
let mut resp_headers = HeaderMap::new();
resp_headers.insert(header::CONTENT_TYPE, "video/mp4".parse().unwrap());
if let Some(content_range) = content_range {
resp_headers.insert(header::CONTENT_RANGE, content_range.parse().unwrap());
}
let len = end - start + 1;
resp_headers.insert(header::CONTENT_LENGTH, len.to_string().parse().unwrap());
resp_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
let body = file_data[start as usize..=end as usize].to_vec();
Ok((status, resp_headers, body))
}
async fn move_video(
Path(filename): Path<String>,
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<impl IntoResponse, AppError> {
let remove = params.get("remove").map(|s| s == "true").unwrap_or(false);
let data = state
.source_access
.read_file(&filename)
.await
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
// Определяем целевую подпапку по имени файла
let subfolder = if filename.contains("_Today") {
"Today"
} else if filename.contains("_Tomorrow") {
"Tomorrow"
} else {
"Data"
};
let dest_filename = format!("{}/{}", subfolder, filename);
// Проверяем, существует ли уже файл в целевой папке
if state.dest_access.read_file(&dest_filename).await.is_ok() {
return Ok((
StatusCode::OK,
format!("File {} already exists in {} folder.", filename, subfolder),
));
}
// Запись
state
.dest_access
.write_file(&dest_filename, &data)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut message = format!(
"File {} moved successfully to {} folder.",
filename, subfolder
);
if remove {
state
.source_access
.delete_file(&filename)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
message.push_str(" Please delete the source file manually if needed.");
}
Ok((StatusCode::OK, message))
}
// Шрифты Font Awesome
async fn fa_solid_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-solid-900.woff2").as_slice(),
)
}
async fn fa_regular_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-regular-400.woff2").as_slice(),
)
}
async fn fa_brands_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-brands-400.woff2").as_slice(),
)
}
async fn get_video_status(
Path(filename): Path<String>,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let subfolder = if filename.contains("_Today") {
"Today"
} else if filename.contains("_Tomorrow") {
"Tomorrow"
} else {
"Data"
};
let dest_filename = format!("{}/{}", subfolder, filename);
let exists = state.dest_access.read_file(&dest_filename).await.is_ok();
Ok(Json(serde_json::json!({
"exists_in_dest": exists,
"subfolder": subfolder
})))
}
struct AppError(StatusCode, String);
impl IntoResponse for AppError {