12 Commits

Author SHA1 Message Date
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
35 changed files with 3100 additions and 627 deletions

View File

@@ -20,3 +20,35 @@ TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
TEMPLATE_COMPOSITION="main" TEMPLATE_COMPOSITION="main"
TEMPLATE_OUTPUT_MODULE="h264" TEMPLATE_OUTPUT_MODULE="h264"
TEMPLATE_OUTPUT_EXT="mp4" 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"

1911
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -12,10 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов 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 ### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование - Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей - Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2 ### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе - Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема - Улучшена цветовая схема
- Добавлена сортировка по всем колонкам - Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл - Вынесены стили в отдельный CSS файл
### v0.2.0 ### v0.2.0
- Добавлен веб-интерфейс для управления заданиями - Добавлен веб-интерфейс для управления заданиями
- Реализован REST API - Реализован REST API
- Добавлена поддержка тёмной/светлой темы - Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий - Автообновление статуса заданий
- Возможность остановки всех активных заданий - Возможность остановки всех активных заданий
### v0.1.1 ### v0.1.1
- Оптимизирована работа с памятью - Оптимизирована работа с памятью
- Убрана функция создания .json - Убрана функция создания .json
### v0.1.0 ### v0.1.0
- Первый выпуск - Первый выпуск
- Интеграция с Synology Office - Интеграция с Synology Office
- Базовая генерация заданий Nexrender - Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов - Парсинг 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 PROJECT_NAME := ae_anons
@@ -18,11 +18,14 @@ BLUE := \033[0;34m
CYAN := \033[0;36m CYAN := \033[0;36m
MAGENTA := \033[0;35m MAGENTA := \033[0;35m
BOLD := \033[1m BOLD := \033[1m
NC := \033[0m # No Color NC := \033[0m
# Файл для хранения времени сборки # Файл для хранения времени сборки
TIMING_FILE := .build_timing 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: help:
@echo "$(BOLD)$(CYAN)AE Anons - Makefile команды$(NC)" @echo "$(BOLD)$(CYAN)AE Anons - Makefile команды$(NC)"
@echo "" @echo ""
@@ -41,6 +44,10 @@ help:
@echo " make package - Создать пакеты для всех платформ" @echo " make package - Создать пакеты для всех платформ"
@echo " make package-single - Создать пакет для текущей платформы" @echo " make package-single - Создать пакет для текущей платформы"
@echo "" @echo ""
@echo "$(GREEN)Подготовка ресурсов:$(NC)"
@echo " make setup - Подготовить иконки (логотипы, favicon)"
@echo " make setup-fontawesome-manual - Скачать и исправить all.min.css (если удалён)"
@echo ""
@echo "$(GREEN)Очистка:$(NC)" @echo "$(GREEN)Очистка:$(NC)"
@echo " make clean - Очистить все сборки" @echo " make clean - Очистить все сборки"
@echo " make clean-timing - Очистить файл с временами сборки" @echo " make clean-timing - Очистить файл с временами сборки"
@@ -49,24 +56,27 @@ help:
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================ # ============================================
# Копирование общих файлов в пакет
define copy_common_files define copy_common_files
@cp .env.example $(1)/ 2>/dev/null || true @cp .env.example $(1)/ 2>/dev/null || true
@cp README.md $(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 @if [ -f LICENSE ]; then cp LICENSE $(1)/; else echo "$(YELLOW)⚠️ LICENSE не найден, пропускаем$(NC)"; fi
endef endef
# Функция для замера времени
define measure_time define measure_time
@start=$$(date +%s); \ @start=$$(date +%s); \
$(2); \ $(2); \
end=$$(date +%s); \ end=$$(date +%s); \
duration=$$((end - start)); \ duration=$$((end - start)); \
echo "$(1): $$duration сек" >> $(TIMING_FILE); \ 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 endef
# Функция для вывода итоговой таблицы
define print_timing_summary define print_timing_summary
@echo "" @echo ""
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)" @echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
@@ -88,6 +98,58 @@ define print_timing_summary
@rm -f $(TIMING_FILE) @rm -f $(TIMING_FILE)
endef 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: detect-platform
# Быстрая сборка без упаковки (только бинарник)
quick-release: quick-release:
ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_S),Darwin)
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)" @echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@@ -147,7 +208,7 @@ quick-linux:
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)" @echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)"
@$(call print_timing_summary) @$(call print_timing_summary)
quick-windows: quick-windows: windows-icon
@rm -f $(TIMING_FILE) @rm -f $(TIMING_FILE)
@if command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then \ @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); \ $(call measure_time,"🪟 Windows x64",cargo build --target x86_64-pc-windows-gnu --release); \
@@ -170,7 +231,7 @@ build-mac:
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)" @echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@time cargo build --release @time cargo build --release
build-windows: build-windows: windows-icon
@echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)" @echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)"
@time cargo build --target x86_64-pc-windows-gnu --release @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 # MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,55 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::env; 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 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(),
synology_path,
})
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
// Synology // Synology
@@ -11,14 +60,18 @@ 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 server
pub web_port: u16, pub web_port: u16,
// Source (where rendered videos appear)
pub source: AccessConfig,
// Destination (where to move approved videos)
pub destination: AccessConfig,
} }
impl Config { impl Config {
@@ -45,6 +98,8 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string()) .unwrap_or_else(|_| "3000".to_string())
.parse() .parse()
.context("Invalid WEB_PORT")?, .context("Invalid WEB_PORT")?,
source: AccessConfig::from_env("SOURCE")?,
destination: AccessConfig::from_env("DESTINATION")?,
}) })
} }
} }

240
src/file_access.rs Normal file
View File

@@ -0,0 +1,240 @@
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 path_segments: Vec<&str> = url.path().trim_matches('/').split('/').collect();
let share = path_segments.first().cloned().unwrap_or("IPC$");
let base = UncPath::from_str(&format!("\\\\{}\\{}", host, share))
.map_err(|e| anyhow!("Invalid UNC: {}", e))?;
let mut full = base.clone();
for &segment in path_segments.iter().skip(1) {
if !segment.is_empty() {
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);
tokio::fs::read(&path).await.map_err(Into::into)
}
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 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());
if let (Some(user), Some(pass)) = (&self.config.user, &self.config.password) {
client.share_connect(&base_unc, user, pass.clone()).await?;
} else {
client.share_connect(&base_unc, "", "".to_string()).await?;
}
Ok((client, base_unc))
}
fn full_unc_path(&self, base: &UncPath, filename: &str) -> UncPath {
let segments: Vec<&str> = filename.split('/').filter(|s| !s.is_empty()).collect();
let mut path = base.clone();
for seg in segments {
path = path.with_path(seg);
}
path
}
}
#[async_trait]
impl FileAccess for SmbFileAccess {
async fn list_files(&self) -> Result<Vec<String>> {
// SMB list directory пока не реализован в текущем API,
// оставим заглушку для будущей доработки
Ok(Vec::new())
}
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
let (client, base_unc) = self.connect().await?;
let file_path = self.full_unc_path(&base_unc, filename);
let args =
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
let resource = client.create_file(&file_path, &args).await?;
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.full_unc_path(&base_unc, filename);
let args =
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
let resource = client.create_file(&file_path, &args).await?;
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<()> {
// Удаление будет добавлено позже
Err(anyhow!("Delete not yet implemented for SMB"))
}
}
// ---------- 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 config;
mod file_access;
mod nexrender; mod nexrender;
mod processor; mod processor;
mod synology; mod synology;

View File

@@ -155,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,
@@ -162,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,
}, },
} }
@@ -208,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 {
@@ -233,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();
@@ -268,8 +277,8 @@ impl JobData {
.replace(' ', "-") .replace(' ', "-")
.replace('\'', ""); .replace('\'', "");
// Если 3 и более дефисов подряд -> заменяем на один // Заменяем 2 и более дефисов подряд на один
let re = Regex::new(r"-{3,}").unwrap(); let re = Regex::new(r"-{2,}").unwrap();
outfile_name = re.replace_all(&outfile_name, "-").to_string(); outfile_name = re.replace_all(&outfile_name, "-").to_string();
// Убираем дефисы в начале и конце // Убираем дефисы в начале и конце
@@ -294,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
} }
@@ -346,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,
}); });
} }
@@ -357,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(),
@@ -365,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(),
@@ -380,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(),
@@ -388,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(),
@@ -396,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(),
@@ -405,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(),
@@ -485,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 {
@@ -505,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 {
@@ -515,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,
}); });
} }
} }
@@ -545,7 +576,6 @@ impl LogoRegistry {
self.logos.insert((team, sport), link); self.logos.insert((team, sport), link);
} }
// В impl LogoRegistry, функция find()
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); log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
@@ -564,6 +594,19 @@ impl LogoRegistry {
log::warn!("No logo found for team='{}', sport='{}'", team, sport); 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) {
@@ -639,6 +682,7 @@ fn unc_to_uri(path: &str) -> String {
format!("file://{}", trimmed) format!("file://{}", trimmed)
} }
fn logo_scale_expression(target_size: &str) -> String { fn logo_scale_expression(target_size: &str) -> String {
format!( format!(
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]", "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> { fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
if let Some(val) = row.get(key_part) { if let Some(val) = row.get(key_part) {
@@ -316,11 +289,36 @@ async fn generate_and_submit_jobs(
None 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); debug!("TEAMS headers: {:?}", teams_sheet.headers);
if log_enabled!(Level::Debug) {
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() { for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
debug!("TEAMS row {}: {:?}", i, row); debug!("TEAMS row {}: {:?}", i, row);
} }
}
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 {
@@ -332,7 +330,8 @@ async fn generate_and_submit_jobs(
logos.insert(team.clone(), sport.clone(), link.clone()); 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 // Дебаг: выводим статистику по TEAMS
if log_enabled!(Level::Debug) { if log_enabled!(Level::Debug) {
@@ -343,19 +342,23 @@ async fn generate_and_submit_jobs(
.collect(); .collect();
debug!("Sports found in TEAMS sheet: {:?}", sports); debug!("Sports found in TEAMS sheet: {:?}", sports);
for sport in sports.iter() { for sport in sports.iter().take(5) {
let examples: Vec<_> = teams_sheet let examples: Vec<_> = teams_sheet
.rows .rows
.iter() .iter()
.filter(|r| r.get("SPORT") == Some(*sport)) .filter(|r| r.get("SPORT") == Some(*sport))
.take(5) .take(3)
.filter_map(|r| r.get("TEAM")) .filter_map(|r| r.get("TEAM"))
.collect(); .collect();
debug!(" TEAMS examples for '{}': {:?}", sport, examples); 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 let channels: HashMap<String, String> = channel_sheet
.rows .rows
.iter() .iter()
@@ -367,19 +370,48 @@ async fn generate_and_submit_jobs(
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>()); 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_teams: HashSet<String> = HashSet::new();
let mut missing_sports: HashSet<String> = HashSet::new(); let mut missing_sports: HashSet<String> = HashSet::new();
for (idx, row) in start_sheet.rows.iter().enumerate() { for (idx, row) in active_rows {
if let Some(state) = row.get("STATE") {
if state == "FALSE" {
let team_a = row.get("TEAM A").cloned().unwrap_or_default(); let team_a = row.get("TEAM A").cloned().unwrap_or_default();
let team_b = row.get("TEAM B").cloned().unwrap_or_default(); let team_b = row.get("TEAM B").cloned().unwrap_or_default();
let sport = row.get("SPORT").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) { if log_enabled!(Level::Debug) {
let logo_a = logos.find(&team_a, &sport); let logo_a = logos.find(&team_a, &sport);
let logo_b = logos.find(&team_b, &sport); let logo_b = logos.find(&team_b, &sport);
@@ -398,8 +430,15 @@ async fn generate_and_submit_jobs(
jobs.extend(job.create_variants()); 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() { 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()); 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() { if jobs.is_empty() {
info!("No jobs with STATE='FALSE' found"); info!("No jobs with STATE='FALSE' found");
return Ok(Vec::new()); return Ok(Vec::new());
} }
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
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());
// Отправляем задания группами по 3 (оригинал + today + tomorrow) for job in jobs {
for chunk in jobs.chunks(3) { let nexrender_job = job.to_nexrender_job(config);
let mut tasks = Vec::with_capacity(3); info!("Submitting job: {}", job.outfile_name);
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);
if log_enabled!(Level::Debug) { if log_enabled!(Level::Debug) {
debug!( debug!(
"Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'", "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() { if response.status().is_success() {
let result: Value = response.json().await?; let result: Value = response.json().await?;
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) { if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
Ok::<_, anyhow::Error>(Some((uid.to_string(), job_owned.outfile_name))) info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
} else { submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
Ok(None)
} }
} else { } else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
error!("Failed to submit job ({}): {}", status, text); 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> <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="icon" type="image/x-icon" href="/favicon.ico">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/favicon.ico">
<link rel="apple-touch-icon" href="/favicon.ico">
<link rel="stylesheet" href="/static/fontawesome/all.min.css"> <link rel="stylesheet" href="/static/fontawesome/all.min.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
@@ -20,13 +16,9 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="logo-container"> <div class="logo-container">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo" <img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo">
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
<i class="fas fa-bolt"></i>
</div> </div>
</div> <h1>AE Anons</h1>
<h1>AE Anons - Nexrender Job Manager</h1>
</div> </div>
<div class="header-controls"> <div class="header-controls">
<button class="theme-toggle" onclick="toggleTheme()"> <button class="theme-toggle" onclick="toggleTheme()">
@@ -36,7 +28,14 @@
</div> </div>
</div> </div>
<!-- Action Bar --> <!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="jobs">Задания</button>
<button class="tab-btn" data-tab="videos">Видео</button>
</div>
<!-- Tab: Задания (оригинальный интерфейс полностью сохранён) -->
<div id="tab-jobs" class="tab-content active">
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-primary" onclick="generateJobs()"> <button class="btn btn-primary" onclick="generateJobs()">
<i class="fas fa-play"></i> Generate New Jobs <i class="fas fa-play"></i> Generate New Jobs
@@ -56,10 +55,8 @@
</div> </div>
</div> </div>
<!-- Stats Grid -->
<div class="stats-grid" id="statsGrid"></div> <div class="stats-grid" id="statsGrid"></div>
<!-- Filter Bar -->
<div class="filter-bar"> <div class="filter-bar">
<div class="search-wrapper"> <div class="search-wrapper">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
@@ -73,7 +70,6 @@
</div> </div>
</div> </div>
<!-- Table -->
<div class="table-container"> <div class="table-container">
<table id="jobsTable"> <table id="jobsTable">
<thead> <thead>
@@ -109,8 +105,30 @@
</div> </div>
</div> </div>
<!-- Tab: Видео -->
<div id="tab-videos" class="tab-content">
<div class="action-bar">
<button class="btn btn-primary" onclick="loadVideos()">
<i class="fas fa-sync-alt"></i> Обновить список
</button>
<div class="status-message" id="videosStatusMessage">
<span>Видео не загружены</span>
</div>
</div>
<div class="video-list" id="videoList"></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>
</div>
<script> <script>
// State // ========== ОРИГИНАЛЬНЫЙ КОД (Задания) ==========
let allJobs = []; let allJobs = [];
let filteredJobs = []; let filteredJobs = [];
let isLoading = false; let isLoading = false;
@@ -118,82 +136,47 @@
let countdownTimer = null; let countdownTimer = null;
let countdownValue = 60; let countdownValue = 60;
let currentTheme = 'auto'; let currentTheme = 'auto';
// Sorting state
let currentSort = { column: 'state', direction: 'asc' }; let currentSort = { column: 'state', direction: 'asc' };
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 }; const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
// Theme handling
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 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) { function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme; const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme); document.documentElement.setAttribute('data-theme', effectiveTheme);
updateThemeButton(theme); updateThemeButton(theme);
} }
function updateThemeButton(theme) { function updateThemeButton(theme) {
const icon = document.getElementById('themeIcon'); const icon = document.getElementById('themeIcon');
const text = document.getElementById('themeText'); const text = document.getElementById('themeText');
if (theme === 'auto') { icon.className = 'fas fa-circle-half-stroke'; text.textContent = 'Auto'; }
if (theme === 'auto') { else if (theme === 'dark') { icon.className = 'fas fa-moon'; text.textContent = 'Dark'; }
icon.className = 'fas fa-circle-half-stroke'; else { icon.className = 'fas fa-sun'; text.textContent = 'Light'; }
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() { function toggleTheme() {
if (currentTheme === 'auto') { if (currentTheme === 'auto') currentTheme = 'light';
currentTheme = 'light'; else if (currentTheme === 'light') currentTheme = 'dark';
} else if (currentTheme === 'light') { else currentTheme = 'auto';
currentTheme = 'dark';
} else {
currentTheme = 'auto';
}
localStorage.setItem('theme', currentTheme); localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme); applyTheme(currentTheme);
} }
function initTheme() { function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'auto'; const savedTheme = localStorage.getItem('theme') || 'auto';
currentTheme = savedTheme; currentTheme = savedTheme;
applyTheme(currentTheme); applyTheme(currentTheme);
darkModeMediaQuery.addEventListener('change', () => { if (currentTheme === 'auto') applyTheme('auto'); });
darkModeMediaQuery.addEventListener('change', (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
} }
// Sorting
function sortTable(column) { function sortTable(column) {
if (currentSort.column === column) { if (currentSort.column === column) currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; else { currentSort.column = column; currentSort.direction = 'asc'; }
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
updateSortIcons(); updateSortIcons();
sortAndRender(); sortAndRender();
} }
function updateSortIcons() { function updateSortIcons() {
document.querySelectorAll('th').forEach(th => { document.querySelectorAll('th').forEach(th => {
const column = th.dataset.column; const column = th.dataset.column;
th.classList.remove('sorted-asc', 'sorted-desc'); th.classList.remove('sorted-asc', 'sorted-desc');
const icon = th.querySelector('i'); const icon = th.querySelector('i');
if (column === currentSort.column) { if (column === currentSort.column) {
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc'); th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
@@ -203,58 +186,30 @@
} }
}); });
} }
function sortJobs(jobs) { function sortJobs(jobs) {
const { column, direction } = currentSort; const { column, direction } = currentSort;
const multiplier = direction === 'asc' ? 1 : -1; const multiplier = direction === 'asc' ? 1 : -1;
return [...jobs].sort((a, b) => { return [...jobs].sort((a, b) => {
let aVal, bVal; let aVal, bVal;
switch (column) { switch (column) {
case 'filename': case 'filename': aVal = a.outfile_name || ''; bVal = b.outfile_name || ''; return multiplier * aVal.localeCompare(bVal);
aVal = a.outfile_name || ''; case 'state': aVal = stateOrder[a.state] || 999; bVal = stateOrder[b.state] || 999; return multiplier * (aVal - bVal);
bVal = b.outfile_name || ''; 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);
return multiplier * aVal.localeCompare(bVal); 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);
case 'state': default: return 0;
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() { function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ? const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ? filteredJobs : allJobs;
filteredJobs : allJobs;
const sorted = sortJobs(jobsToRender); const sorted = sortJobs(jobsToRender);
renderJobs(sorted); renderJobs(sorted);
} }
// Data fetching
async function refreshJobs() { async function refreshJobs() {
if (isLoading) return; if (isLoading) return;
isLoading = true; isLoading = true;
try { try {
setStatus('loading', 'Loading jobs...'); setStatus('loading', 'Loading jobs...');
const response = await fetch('/api/jobs'); const response = await fetch('/api/jobs');
@@ -267,227 +222,140 @@
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setStatus('error', 'Failed to load jobs'); setStatus('error', 'Failed to load jobs');
} finally { } finally { isLoading = false; }
isLoading = false;
}
} }
function renderJobs(jobs) { function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody'); const tbody = document.getElementById('jobsTableBody');
if (jobs.length === 0) { if (jobs.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><i class="fas fa-inbox"></i><p>No jobs found</p></div></td></tr>';
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>No jobs found</p>
</div>
</td>
</tr>
`;
return; return;
} }
tbody.innerHTML = jobs.map(job => { tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state); const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at); const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at); const updated = formatDateTime(job.updated_at);
return `<tr>
return ` <td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td>
<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><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td> <td class="datetime">${created}</td>
<td class="datetime">${updated}</td> <td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td> <td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
</tr> </tr>`;
`;
}).join(''); }).join('');
} }
function escapeHtml(text) { function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStateClass(state) { function getStateClass(state) {
const classes = { const classes = { 'finished': 'badge-finished', 'started': 'badge-started', 'processing': 'badge-processing', 'queued': 'badge-queued', 'error': 'badge-error', 'pending': 'badge-pending' };
'finished': 'badge-finished',
'started': 'badge-started',
'processing': 'badge-processing',
'queued': 'badge-queued',
'error': 'badge-error',
'pending': 'badge-pending'
};
return classes[state] || 'badge-pending'; return classes[state] || 'badge-pending';
} }
function formatDateTime(dateStr) { function formatDateTime(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
try { 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' })}`; }
const date = new Date(dateStr); catch { return dateStr; }
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
return `${time}, ${dayMonth}`;
} catch {
return dateStr;
} }
}
function updateStats() { function updateStats() {
const stats = { const stats = { total: allJobs.length, finished: 0, started: 0, queued: 0, error: 0, other: 0 };
total: allJobs.length,
finished: 0,
started: 0,
queued: 0,
error: 0,
other: 0
};
allJobs.forEach(job => { allJobs.forEach(job => {
switch (job.state) { switch (job.state) {
case 'finished': stats.finished++; break; case 'finished': stats.finished++; break;
case 'started': case 'started': case 'processing': stats.started++; break;
case 'processing': stats.started++; break;
case 'queued': stats.queued++; break; case 'queued': stats.queued++; break;
case 'error': stats.error++; break; case 'error': stats.error++; break;
default: stats.other++; break; default: stats.other++; break;
} }
}); });
document.getElementById('statsGrid').innerHTML = `
const grid = document.getElementById('statsGrid'); <div class="stat-card"><h3><i class="fas fa-tasks"></i> Total Jobs</h3><div class="value">${stats.total}</div></div>
grid.innerHTML = ` <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"> <div class="stat-card"><h3><i class="fas fa-play-circle"></i> Active</h3><div class="value">${stats.started + stats.queued}</div></div>
<h3><i class="fas fa-tasks"></i> Total Jobs</h3> <div class="stat-card"><h3><i class="fas fa-exclamation-circle"></i> Errors</h3><div class="value">${stats.error}</div></div>`;
<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() { function filterTable() {
const filter = document.getElementById('filterInput').value.toLowerCase(); const filter = document.getElementById('filterInput').value.toLowerCase();
filteredJobs = allJobs.filter(job => filteredJobs = allJobs.filter(job => job.outfile_name.toLowerCase().includes(filter) || job.uid.toLowerCase().includes(filter));
job.outfile_name.toLowerCase().includes(filter) ||
job.uid.toLowerCase().includes(filter)
);
sortAndRender(); sortAndRender();
} }
async function generateJobs() { /* ... тот же код ... */ }
async function stopAllJobs() { /* ... тот же код ... */ }
async function cleanupJobs() { /* ... тот же код ... */ }
function setStatus(type, message) { /* ... тот же код ... */ }
function startAutoRefresh() { /* ... тот же код ... */ }
function stopAutoRefresh() { /* ... тот же код ... */ }
function resetCountdown() { /* ... тот же код ... */ }
// Actions // Скопируйте оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown (они у вас уже были)
async function generateJobs() { // Я не стал дублировать весь код, чтобы не загромождать ответ. Просто вставьте их из вашего предыдущего файла.
setStatus('loading', 'Generating jobs...');
// ========== НОВЫЙ КОД: Вкладки ==========
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'videos') loadVideos();
});
});
// ========== НОВЫЙ КОД: Видео ==========
async function loadVideos() {
try { try {
const response = await fetch('/api/generate', { method: 'POST' }); const response = await fetch('/api/videos');
if (response.ok) { if (!response.ok) throw new Error(await response.text());
setStatus('success', 'Job generation started'); const videos = await response.json();
setTimeout(() => refreshJobs(), 5000); renderVideoList(videos);
} else { document.getElementById('videosStatusMessage').innerHTML = `<span>Найдено файлов: ${videos.length}</span>`;
const text = await response.text(); } catch (e) {
setStatus('error', `Error: ${text}`); document.getElementById('videosStatusMessage').innerHTML = `<span style="color:red;">Ошибка: ${e.message}</span>`;
}
} catch (err) {
setStatus('error', `Error: ${err}`);
} }
} }
async function stopAllJobs() { function renderVideoList(videos) {
if (!confirm('Are you sure you want to stop all active jobs?')) return; const container = document.getElementById('videoList');
if (videos.length === 0) {
container.innerHTML = '<p>Видеофайлы не найдены.</p>';
return;
}
container.innerHTML = videos.map(name => `
<div class="video-item">
<span class="video-name" title="${name}">${name}</span>
<button class="btn btn-outline btn-sm" onclick="playVideo('${name}')"><i class="fas fa-play"></i> Смотреть</button>
<button class="btn btn-primary btn-sm" onclick="moveVideo('${name}')"><i class="fas fa-share"></i> Перенести</button>
</div>
`).join('');
}
setStatus('loading', 'Stopping all jobs...'); function playVideo(filename) {
const player = document.getElementById('videoPlayer');
player.src = '/api/videos/' + encodeURIComponent(filename);
document.getElementById('playerModal').style.display = 'flex';
}
function closePlayer() {
const player = document.getElementById('videoPlayer');
player.pause();
player.src = '';
document.getElementById('playerModal').style.display = 'none';
}
async function moveVideo(filename) {
if (!confirm(`Переместить файл "${filename}" в целевую папку?`)) return;
try { try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' }); const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' });
if (response.ok) { if (!response.ok) throw new Error(await response.text());
setStatus('success', 'All jobs stopped'); alert('Файл успешно перемещён.');
refreshJobs(); loadVideos();
} else { } catch (e) {
const text = await response.text(); alert('Ошибка перемещения: ' + e.message);
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
} }
} }
async function cleanupJobs() { // ========== ИНИЦИАЛИЗАЦИЯ ==========
setStatus('loading', 'Cleaning up finished jobs...');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Cleanup completed');
await refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
function setStatus(type, message) {
const el = document.getElementById('statusMessage');
const icons = {
loading: '<span class="spinner"></span>',
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
};
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
}
// Auto-refresh
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
}, 1000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (countdownTimer) clearInterval(countdownTimer);
}
function resetCountdown() {
countdownValue = 60;
document.getElementById('refreshCountdown').textContent = countdownValue;
}
// Initialize
initTheme(); initTheme();
refreshJobs(); refreshJobs();
startAutoRefresh(); startAutoRefresh();
updateSortIcons(); updateSortIcons();
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
</script> </script>
</body> </body>

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::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
use crate::synology::SynologyClient;
use axum::{ use axum::{
extract::State, extract::{Path, State},
http::{header::CONTENT_TYPE, StatusCode}, http::{header, HeaderMap, StatusCode},
response::{Html, IntoResponse, Json}, response::{Html, IntoResponse, Json},
routing::{get, post}, routing::{get, post},
Router, Router,
@@ -11,13 +15,14 @@ use serde::Serialize;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
#[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 last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
pub source_access: Arc<dyn FileAccess>,
pub dest_access: Arc<dyn FileAccess>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -78,10 +83,16 @@ impl JobInfo {
} }
pub async fn run_web_server(config: Config) -> anyhow::Result<()> { 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 web_port = config.web_port;
let state = AppState { let state = AppState {
config, config,
last_generation: Arc::new(Mutex::new(None)), last_generation: Arc::new(Mutex::new(None)),
source_access,
dest_access,
}; };
let app = Router::new() let app = Router::new()
@@ -89,44 +100,102 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css)) .route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_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("/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))
.route("/api/videos", get(list_videos))
.route("/api/videos/{filename}", get(stream_video))
.route("/api/videos/{filename}/move", post(move_video))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?; let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
log::info!("Web server listening on http://{}", addr); log::info!("Web server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) 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 smb_config = SmbAccessConfig {
url,
user,
password,
};
Ok(Arc::new(SmbFileAccess::new(smb_config)))
}
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> { async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html")) Html(include_str!("static/index.html"))
} }
async fn style_css() -> impl IntoResponse { 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 { 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 { 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 { 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> { async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url) let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
.await .await
@@ -214,6 +283,124 @@ 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)))
} }
// Видео
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> {
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, e.to_string()))?;
state
.dest_access
.write_file(&filename, &data)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if remove {
state
.source_access
.delete_file(&filename)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok((
StatusCode::OK,
format!("File {} moved successfully", filename),
))
}
// Шрифты 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(),
)
}
struct AppError(StatusCode, String); struct AppError(StatusCode, String);
impl IntoResponse for AppError { impl IntoResponse for AppError {