9 Commits

24 changed files with 2704 additions and 291 deletions

View File

@@ -19,4 +19,36 @@ TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep" 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"

2
.gitignore vendored
View File

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

1892
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.6" 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"
@@ -49,8 +52,5 @@ lto = true
codegen-units = 1 codegen-units = 1
strip = true strip = true
[target.'cfg(windows)'.dependencies]
winres = "0.1"
[build-dependencies] [build-dependencies]
winres = "0.1" 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

Binary file not shown.

BIN
assets/logo.icns Normal file

Binary file not shown.

View File

@@ -1,9 +1,11 @@
// build.rs
fn main() { fn main() {
#[cfg(windows)] #[cfg(windows)]
{ {
let mut res = winres::WindowsResource::new(); if let Err(e) = winres::WindowsResource::new()
res.set_icon("assets/logo.ico"); .set_icon("assets/logo.ico")
res.compile().unwrap(); .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 icons favicon .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
@@ -44,9 +44,9 @@ help:
@echo " make package - Создать пакеты для всех платформ" @echo " make package - Создать пакеты для всех платформ"
@echo " make package-single - Создать пакет для текущей платформы" @echo " make package-single - Создать пакет для текущей платформы"
@echo "" @echo ""
@echo "$(GREEN)Иконки:$(NC)" @echo "$(GREEN)Подготовка ресурсов:$(NC)"
@echo " make icons - Создать все иконки (logo.ico, logo.icns, favicon.ico)" @echo " make setup - Подготовить иконки (логотипы, favicon)"
@echo " make favicon - Создать только favicon.ico" @echo " make setup-fontawesome-manual - Скачать и исправить all.min.css (если удалён)"
@echo "" @echo ""
@echo "$(GREEN)Очистка:$(NC)" @echo "$(GREEN)Очистка:$(NC)"
@echo " make clean - Очистить все сборки" @echo " make clean - Очистить все сборки"
@@ -67,15 +67,14 @@ define measure_time
$(2); \ $(2); \
end=$$(date +%s); \ end=$$(date +%s); \
duration=$$((end - start)); \ duration=$$((end - start)); \
echo "$(1): $$duration сек" >> $(TIMING_FILE); \
if [ $$duration -ge 60 ]; then \ if [ $$duration -ge 60 ]; then \
min=$$((duration / 60)); \ min=$$((duration / 60)); \
sec=$$((duration % 60)); \ sec=$$((duration % 60)); \
time_str="$${min}m $${sec}s"; \ echo "$(GREEN)$(1) завершён за $${min}m $${sec}s$(NC)"; \
else \ else \
time_str="$${duration}s"; \ echo "$(GREEN)$(1) завершён за $${duration}s$(NC)"; \
fi; \ fi
echo "$(1): $$time_str" >> $(TIMING_FILE); \
echo "$(GREEN)$(1) завершён за $$time_str$(NC)"
endef endef
define print_timing_summary define print_timing_summary
@@ -100,41 +99,56 @@ define print_timing_summary
endef endef
# ============================================ # ============================================
# ИКОНКИ # ПОДГОТОВКА РЕСУРСОВ
# ============================================ # ============================================
icons: favicon setup: icons
@echo "$(GREEN)Создание иконок для Windows и macOS...$(NC)" @echo "$(GREEN)Все статические ресурсы подготовлены$(NC)"
# Генерация иконок (favicon, .ico, .icns) из логотипа
icons:
@echo "$(GREEN)🎨 Генерация иконок...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \ @if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \ echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \ exit 1; \
fi 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 # Windows .ico
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=256,128,64,48,32,16 assets/logo.ico @$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=256,128,64,48,32,16 assets/logo.ico
@echo "$(GREEN) ✓ assets/logo.ico$(NC)" @echo "$(GREEN) ✓ assets/logo.ico$(NC)"
# macOS .icns # macOS .icns
@mkdir -p assets/icon.iconset @mkdir -p assets/icon.iconset
@sips -z 16 16 assets/logo.png --out assets/icon.iconset/icon_16x16.png 2>/dev/null @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 @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 @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 @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 @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 @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 @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 @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 @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 @iconutil -c icns assets/icon.iconset -o assets/logo.icns 2>/dev/null || true
@rm -rf assets/icon.iconset @rm -rf assets/icon.iconset
@echo "$(GREEN) ✓ assets/logo.icns$(NC)" @echo "$(GREEN) ✓ assets/logo.icns$(NC)"
favicon: # Восстановить CSS Font Awesome вручную (если файл удалён)
@echo "$(GREEN)Создание favicon.ico...$(NC)" setup-fontawesome-manual:
@if [ "$(IMAGEMAGICK)" = "false" ]; then \ @echo "$(GREEN)📥 Скачивание Font Awesome CSS...$(NC)"
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \ @curl -sL -o src/static/fontawesome/all.min.css $(FONTAWESOME_CSS_URL)
exit 1; \ @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 fi
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=48,32,16 src/static/favicon.ico
@echo "$(GREEN) ✓ src/static/favicon.ico$(NC)"
# ============================================ # ============================================
# ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ # ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ
@@ -194,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); \
@@ -217,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

@@ -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

@@ -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

@@ -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

@@ -1,6 +1,57 @@
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 smb_domain: Option<String>,
pub synology_path: Option<String>,
}
impl AccessConfig {
fn from_env(prefix: &str) -> Result<Self> {
let local_path = env::var(format!("{}_LOCAL_PATH", prefix)).ok();
let smb_url = env::var(format!("{}_SMB_URL", prefix)).ok();
let synology_path = env::var(format!("{}_SYNOLOGY_PATH", prefix)).ok();
let access_type = if local_path.is_some() && smb_url.is_none() && synology_path.is_none() {
AccessType::Local
} else if smb_url.is_some() && local_path.is_none() && synology_path.is_none() {
AccessType::Smb
} else if synology_path.is_some() && local_path.is_none() && smb_url.is_none() {
AccessType::Synology
} else {
anyhow::bail!(
"Exactly one of {}_LOCAL_PATH, {}_SMB_URL, {}_SYNOLOGY_PATH must be set",
prefix,
prefix,
prefix
);
};
Ok(AccessConfig {
access_type,
local_path,
smb_url,
smb_user: env::var(format!("{}_SMB_USER", prefix)).ok(),
smb_pass: env::var(format!("{}_SMB_PASS", prefix)).ok(),
smb_domain: env::var(format!("{}_SMB_DOMAIN", prefix)).ok(),
synology_path,
})
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
// Synology // Synology
@@ -11,14 +62,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 +100,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")?,
}) })
} }
} }

287
src/file_access.rs Normal file
View File

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

View File

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

View File

@@ -5,12 +5,7 @@
<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</title> <title>AE Anons</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/assets/logo.png">
<!-- CSS -->
<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>
@@ -21,11 +16,7 @@
<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</h1>
</div> </div>
@@ -94,11 +85,13 @@
<th data-column="uid" onclick="sortTable('uid')"> <th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i> UID <i class="fas fa-sort"></i>
</th> </th>
<th>Preview</th>
<th>Move</th>
</tr> </tr>
</thead> </thead>
<tbody id="jobsTableBody"> <tbody id="jobsTableBody">
<tr> <tr>
<td colspan="5"> <td colspan="7">
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-spinner fa-spin"></i> <i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p> <p>Loading jobs...</p>
@@ -110,8 +103,16 @@
</div> </div>
</div> </div>
<!-- Video Player Modal -->
<div id="playerModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closePlayer()">&times;</span>
<video id="videoPlayer" controls style="width:100%; max-height:70vh;"></video>
</div>
</div>
<script> <script>
// State // ========== ОРИГИНАЛЬНЫЙ КОД (Задания) ==========
let allJobs = []; let allJobs = [];
let filteredJobs = []; let filteredJobs = [];
let isLoading = false; let isLoading = false;
@@ -119,82 +120,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');
@@ -204,58 +170,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');
@@ -268,130 +206,73 @@
} 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="7"><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);
const previewBtn = job.state === 'finished'
return ` ? `<button class="btn btn-outline btn-sm" onclick="playVideo('${escapeHtml(job.outfile_name)}')"><i class="fas fa-play"></i></button>`
<tr> : '';
<td> // Заглушка‑иконка, обновится асинхронно
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div> const moveIcon = job.state === 'finished'
</td> ? `<span class="move-icon" data-file="${escapeHtml(job.outfile_name)}"><i class="fas fa-spinner fa-spin"></i></span>`
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td> : '';
<td class="datetime">${created}</td> return `<tr>
<td class="datetime">${updated}</td> <td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td> <td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
</tr> <td class="datetime">${created}</td>
`; <td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
<td>${previewBtn}</td>
<td>${moveIcon}</td>
</tr>`;
}).join(''); }).join('');
// Запускаем проверку статусов после рендера
updateMoveStatuses();
} }
function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStateClass(state) { 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();
} }
// Actions // Оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown
async function generateJobs() { async function generateJobs() {
setStatus('loading', 'Generating jobs...'); setStatus('loading', 'Generating jobs...');
try { try {
@@ -410,7 +291,6 @@
async function stopAllJobs() { async function stopAllJobs() {
if (!confirm('Are you sure you want to stop all active jobs?')) return; if (!confirm('Are you sure you want to stop all active jobs?')) return;
setStatus('loading', 'Stopping all jobs...'); setStatus('loading', 'Stopping all jobs...');
try { try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' }); const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
@@ -452,21 +332,13 @@
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`; el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
} }
// Auto-refresh
function startAutoRefresh() { function startAutoRefresh() {
stopAutoRefresh(); stopAutoRefresh();
autoRefreshTimer = setInterval(() => { refreshJobs(); resetCountdown(); }, 60000);
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
countdownTimer = setInterval(() => { countdownTimer = setInterval(() => {
countdownValue--; countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue; document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) { if (countdownValue <= 0) countdownValue = 60;
countdownValue = 60;
}
}, 1000); }, 1000);
} }
@@ -480,15 +352,56 @@
document.getElementById('refreshCountdown').textContent = countdownValue; document.getElementById('refreshCountdown').textContent = countdownValue;
} }
// Initialize // ========== НОВЫЙ КОД: Видео ==========
function playVideo(filename) {
const player = document.getElementById('videoPlayer');
player.src = '/api/videos/' + encodeURIComponent(filename);
document.getElementById('playerModal').classList.add('active');
}
function closePlayer() {
const player = document.getElementById('videoPlayer');
player.pause();
player.src = '';
document.getElementById('playerModal').classList.remove('active');
}
async function moveVideo(filename) {
if (!confirm(`Переместить файл "${filename}" в целевую папку?`)) return;
try {
const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
alert('Файл успешно перемещён.');
refreshJobs(); // обновим список, возможно, файл исчезнет
} catch (e) {
alert('Ошибка перемещения: ' + e.message);
}
}
async function updateMoveStatuses() {
const icons = document.querySelectorAll('.move-icon');
for (const icon of icons) {
const filename = icon.dataset.file;
try {
const resp = await fetch('/api/videos/' + encodeURIComponent(filename) + '/status');
const status = await resp.json();
const exists = status.exists_in_dest;
const subfolder = status.subfolder;
if (exists) {
icon.innerHTML = `<i class="fas fa-check-circle" style="color: var(--accent-success);" title="Already in ${subfolder}"></i>`;
} else {
icon.innerHTML = `<button class="btn btn-primary btn-sm" onclick="moveVideo('${escapeHtml(filename)}')"><i class="fas fa-share"></i></button>`;
}
} catch (e) {
icon.innerHTML = `<i class="fas fa-question-circle" title="Status unknown"></i>`;
}
}
}
// ========== ИНИЦИАЛИЗАЦИЯ ==========
initTheme(); initTheme();
refreshJobs(); refreshJobs();
startAutoRefresh(); startAutoRefresh();
updateSortIcons(); updateSortIcons();
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
</script> </script>
</body> </body>

View File

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

View File

@@ -1,8 +1,12 @@
use crate::config::Config; use crate::config::{AccessType, Config};
use crate::file_access::{
FileAccess, LocalFileAccess, SmbAccessConfig, SmbFileAccess, SynologyFileAccess,
};
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet}; use crate::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,59 +100,106 @@ 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))
.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))
.nest_service( .route("/api/videos", get(list_videos))
"/static/fontawesome/webfonts", .route("/api/videos/{filename}", get(stream_video))
ServeDir::new("src/static/fontawesome/webfonts"), .route("/api/videos/{filename}/move", post(move_video))
) .route("/api/videos/{filename}/status", get(get_video_status))
.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 domain = access_config.smb_domain.clone();
let smb_config = SmbAccessConfig {
url,
user,
password,
domain,
};
let access: Arc<dyn FileAccess> = Arc::new(SmbFileAccess::new(smb_config));
Ok(access)
}
AccessType::Synology => {
let mut client = SynologyClient::new(&nas_config.nas_fqdn);
client
.login(&nas_config.nas_user, &nas_config.nas_pass)
.await?;
let base_path = access_config.synology_path.as_ref().unwrap().clone();
Ok(Arc::new(SynologyFileAccess::new(client, &base_path)))
}
}
}
// Статические ресурсы
async fn index_page() -> Html<&'static str> { 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")], [(header::CONTENT_TYPE, "text/css")],
include_str!("static/style.css"), include_str!("static/style.css"),
) )
} }
async fn fontawesome_css() -> impl IntoResponse { async fn fontawesome_css() -> impl IntoResponse {
( (
[(CONTENT_TYPE, "text/css")], [(header::CONTENT_TYPE, "text/css")],
include_str!("static/fontawesome/all.min.css"), include_str!("static/fontawesome/all.min.css"),
) )
} }
async fn logo_png() -> impl IntoResponse { async fn logo_png() -> impl IntoResponse {
( (
[(CONTENT_TYPE, "image/png")], [(header::CONTENT_TYPE, "image/png")],
include_bytes!("../assets/logo.png").as_slice(), include_bytes!("../assets/logo.png").as_slice(),
) )
} }
async fn favicon() -> impl IntoResponse { async fn favicon() -> impl IntoResponse {
( (
[(CONTENT_TYPE, "image/x-icon")], [(header::CONTENT_TYPE, "image/x-icon")],
include_bytes!("static/favicon.ico").as_slice(), 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
@@ -229,6 +287,166 @@ 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> {
log::info!("Stream video requested: {}", filename);
let file_data = state
.source_access
.read_file(&filename)
.await
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
let file_size = file_data.len() as u64;
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
let (status, content_range, start, end) = if let Some(range_str) = range {
let range_str = range_str.trim_start_matches("bytes=");
let parts: Vec<&str> = range_str.split('-').collect();
let start: u64 = parts[0].parse().unwrap_or(0);
let end: u64 = if parts.len() > 1 && !parts[1].is_empty() {
parts[1].parse().unwrap_or(file_size - 1)
} else {
file_size - 1
};
(
StatusCode::PARTIAL_CONTENT,
Some(format!("bytes {}-{}/{}", start, end, file_size)),
start,
end,
)
} else {
(StatusCode::OK, None, 0, file_size - 1)
};
let mut resp_headers = HeaderMap::new();
resp_headers.insert(header::CONTENT_TYPE, "video/mp4".parse().unwrap());
if let Some(content_range) = content_range {
resp_headers.insert(header::CONTENT_RANGE, content_range.parse().unwrap());
}
let len = end - start + 1;
resp_headers.insert(header::CONTENT_LENGTH, len.to_string().parse().unwrap());
resp_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
let body = file_data[start as usize..=end as usize].to_vec();
Ok((status, resp_headers, body))
}
async fn move_video(
Path(filename): Path<String>,
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<impl IntoResponse, AppError> {
let remove = params.get("remove").map(|s| s == "true").unwrap_or(false);
let data = state
.source_access
.read_file(&filename)
.await
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
// Определяем целевую подпапку по имени файла
let subfolder = if filename.contains("_Today") {
"Today"
} else if filename.contains("_Tomorrow") {
"Tomorrow"
} else {
"Data"
};
let dest_filename = format!("{}/{}", subfolder, filename);
// Проверяем, существует ли уже файл в целевой папке
if state.dest_access.read_file(&dest_filename).await.is_ok() {
return Ok((
StatusCode::OK,
format!("File {} already exists in {} folder.", filename, subfolder),
));
}
// Запись
state
.dest_access
.write_file(&dest_filename, &data)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut message = format!(
"File {} moved successfully to {} folder.",
filename, subfolder
);
if remove {
state
.source_access
.delete_file(&filename)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
message.push_str(" Please delete the source file manually if needed.");
}
Ok((StatusCode::OK, message))
}
// Шрифты Font Awesome
async fn fa_solid_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-solid-900.woff2").as_slice(),
)
}
async fn fa_regular_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-regular-400.woff2").as_slice(),
)
}
async fn fa_brands_woff2() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-brands-400.woff2").as_slice(),
)
}
async fn get_video_status(
Path(filename): Path<String>,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let subfolder = if filename.contains("_Today") {
"Today"
} else if filename.contains("_Tomorrow") {
"Tomorrow"
} else {
"Data"
};
let dest_filename = format!("{}/{}", subfolder, filename);
let exists = state.dest_access.read_file(&dest_filename).await.is_ok();
Ok(Json(serde_json::json!({
"exists_in_dest": exists,
"subfolder": subfolder
})))
}
struct AppError(StatusCode, String); struct AppError(StatusCode, String);
impl IntoResponse for AppError { impl IntoResponse for AppError {