Причёсинг

This commit is contained in:
2026-04-15 21:17:14 +03:00
parent 11fc697771
commit 9c73a8672e
7 changed files with 641 additions and 95 deletions

23
.gitignore vendored
View File

@@ -1,7 +1,22 @@
/target
.env
.DS_Store .DS_Store
*.xlsx *.xlsx
*.osheet
*.json
*.txt *.txt
# Файлы окружения
.env
# Логи
logs/
*.log
# Выходные файлы
output/
*.xlsx
*.json
# IDE
.vscode/
.idea/
*.swp
# Скомпилированные файлы
target/

138
Cargo.lock generated
View File

@@ -16,7 +16,9 @@ dependencies = [
"calamine", "calamine",
"chrono", "chrono",
"dotenv", "dotenv",
"env_logger",
"futures", "futures",
"log",
"regex", "regex",
"reqwest", "reqwest",
"serde", "serde",
@@ -44,6 +46,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -154,6 +206,12 @@ dependencies = [
"encoding_rs", "encoding_rs",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -232,6 +290,29 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -705,12 +786,42 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.95" version = "0.3.95"
@@ -831,6 +942,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.77" version = "0.10.77"
@@ -916,6 +1033,21 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1530,6 +1662,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -16,6 +16,8 @@ chrono = "0.4"
regex = "1.11" regex = "1.11"
futures = "0.3" futures = "0.3"
anyhow = "1.0" anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] } tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }

346
src/README.md Normal file
View File

@@ -0,0 +1,346 @@
# AE Anons - Automated After Effects Announcement Generator
Automated pipeline for generating sports announcements using After Effects templates via Nexrender, with data sourced from Synology Office spreadsheets.
## Overview
AE Anons automates the creation of broadcast announcement videos by:
1. Connecting to Synology NAS to retrieve schedule data from Office spreadsheets (.osheet)
2. Parsing Excel data containing sports events, teams, channels and timing information
3. Generating Nexrender jobs with appropriate templates and assets
4. Monitoring render completion and managing output files
## Features
- **Synology Integration**: Seamless authentication and file retrieval from Synology NAS
- **Office Spreadsheet Export**: Automatic conversion of .osheet files to Excel format
- **Flexible Data Parsing**: Dynamic sheet parsing with header detection
- **Multi-variant Generation**: Creates "Today", "Tomorrow" and dated variants for each announcement
- **Smart Logo Management**: Automatic logo resolution and scaling based on team/sport associations
- **Nexrender Job Orchestration**: Automated job submission, monitoring and cleanup
- **Professional Logging**: Structured logging with configurable verbosity levels
- **Environment-based Configuration**: All settings managed via `.env` file
## Prerequisites
- Rust 1.70 or higher
- Access to Synology NAS with File Station and Office enabled
- Nexrender server instance
- After Effects templates configured on render nodes
## Installation
### 1. Clone the Repository
```bash
git clone https://github.com/your-org/ae_anons.git
cd ae_anons
2. Build the Project
bash
cargo build --release
The binary will be available at target/release/ae_anons
3. Configure Environment
Create a .env file in the project root:
env
NAS_FQDN=https://your-synology-nas.example.com:5001
NAS_USER=service_account
NAS_PASS=secure_password
NAS_FILE=/Team Folder/Broadcast/Auto_Anons/schedule.osheet
NEXRENDER_API_URL=http://nexrender-server:3000/api/v1/jobs
OUTPUT_FOLDER=//file-server/edit/Auto_Anons
RUST_LOG=info
See Configuration section for detailed options.
Usage
Basic Execution
bash
# Run with default configuration
./target/release/ae_anons
# Run with custom log level
RUST_LOG=debug ./target/release/ae_anons
# Run with specific .env file
dotenv -f /path/to/.env.production run ./target/release/ae_anons
Development Mode
bash
cargo run
Logging Levels
Control output verbosity via RUST_LOG environment variable:
error - Only critical errors
warn - Warnings and errors
info - General operational messages (default)
debug - Detailed processing information
trace - Full debugging with API call details
bash
RUST_LOG=debug cargo run
Configuration
Environment Variables
Variable Required Default Description
NAS_FQDN Yes - Synology NAS URL with protocol and port
NAS_USER Yes - Synology account username
NAS_PASS Yes - Synology account password
NAS_FILE Yes - Full path to .osheet file on NAS
NEXRENDER_API_URL
OUTPUT_FOLDER Network path for rendered videos
RUST_LOG No info Logging verbosity level
Spreadsheet Structure
The input Excel file (converted from .osheet) must contain the following sheets:
Start Sheet
Main data source for announcements generation.
Column Description Example
STATE Processing flag FALSE (active), TRUE (skip)
SPORT Sport category Футбол, Хоккей
LEAGUE League name Премьер-лига
TEAM A Home team Спартак#FC Spartak#150
TEAM B Away team Зенит#FC Zenit#150
CHANEL Broadcast channel Матч ТВ
TIME Event time 19:30
DATA Event date 15.04.2026
SPORT Sheet
Mapping between sports and their video pack templates.
Column Description
SPORT Sport identifier
LINK Path to video pack file
TEAMS Sheet
Team logo registry with sport associations.
Column Description
TEAM Team identifier
SPORT Associated sport
LINK Path to team logo file
CHANELL Sheet
Channel logo mappings.
Column Description
CHANELL Channel name
LINK Path to channel logo file
Team Name Format
Team names can include resolution hints using hash separators:
text
Display Name#Search Key#Target Size
Example: Спартак#FC Spartak#150
Спартак - Display name in graphics
FC Spartak - Key for logo lookup
150 - Target size in pixels for logo scaling
Output Files
JSON Exports
During processing, JSON representations of the workbook are saved:
{filename}_workbook.json - Complete workbook structure
{filename}_{SheetName}.json - Individual sheet data
Rendered Videos
Output videos are saved to OUTPUT_FOLDER with naming pattern:
text
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
Examples:
20260415_Футбол_Премьер-лига_Спартак_Зенит_Матч-ТВ.mp4 (Base version)
20260415_Футбол_Премьер-лига_Спартак_Зенит_Матч-ТВ_Today.mp4
20260415_Футбол_Премьер-лига_Спартак_Зенит_Матч-ТВ_Tomorrow.mp4
Workflow
Authentication: Connects to Synology NAS using provided credentials
File Retrieval: Locates and exports the specified .osheet file as Excel
Data Parsing: Reads all sheets and structures the data
Asset Resolution: Matches sports, teams and channels with their visual assets
Job Generation: Creates Nexrender jobs for each active row with variants
Cleanup: Removes completed/failed jobs from previous runs
Submission: Sends jobs to Nexrender API
Monitoring: Tracks job progress until completion
Logout: Terminates Synology session
After Effects Template Requirements
Templates must be pre-configured on render nodes with specific layer names:
Template Types
Template File Use Case
Double Team PackShot_DOUBLE.aepx Matches with two teams
Single Team PackShot_SINGLE.aepx Single team announcements
Required Layers
Layer Name Type Description
DATA Text Date display (auto-adjusted)
TIME / TIME_H / TIME_M Text Time display
LEAGUE Text League name
SPORT Text Sport category
TEAMS Text Combined team names
TEAM_A_LOGO Image Home team logo
TEAM_B_LOGO Image Away team logo
CHANELL Image Channel logo
TOP Video Sport pack overlay
Composition Settings
Composition name: pack
Output module: Start_h264
Output format: mp4
Troubleshooting
Common Issues
Connection to NAS failed
Verify NAS_FQDN includes protocol and port (e.g., https://nas.example.com:5001)
Check network connectivity to NAS
Ensure File Station and Office services are enabled
File not found
Verify the path in NAS_FILE exactly matches the Synology Drive path
Path should start with /Team Folder/ for team folders
Check file permissions for the service account
Nexrender job submission fails
Confirm Nexrender server is accessible
Verify NEXRENDER_API_URL is correct
Check that template files exist on render nodes
Debug Mode
Enable debug logging for detailed troubleshooting:
bash
RUST_LOG=debug ./target/release/ae_anons
This will output:
API request/response details
Sheet parsing information
Job creation details
Asset resolution process
Performance Considerations
Large Spreadsheets: Processing limited to 10,000 rows per sheet
Network Latency: File downloads from NAS may take time for large files
Concurrent Jobs: Nexrender handles job queuing internally
Memory Usage: Excel parsing keeps entire workbook in memory
Security Notes
Store credentials only in .env file (excluded from git)
Use dedicated service accounts with minimal required permissions
Synology sessions are properly terminated after execution
HTTPS recommended for NAS connections in production
Development
Running Tests
bash
cargo test
Code Structure
text
src/
├── main.rs # Application entry point and orchestration
├── config.rs # Configuration management
├── nexrender.rs # Nexrender job generation and structures
└── synology.rs # Synology API client
Adding New Features
Extend JobData in nexrender.rs for new data fields
Update sheet parsing logic if new columns are required
Add corresponding After Effects layers to templates
Update to_nexrender_job() method with new asset mappings
Dependencies
Crate Version Purpose
reqwest 0.12 HTTP client for API communication
serde / serde_json 1.0 JSON serialization
calamine 0.26 Excel file parsing
chrono 0.4 Date/time handling
tokio 1.0 Async runtime
dotenv 0.15 Environment configuration
log / env_logger 0.4 Logging infrastructure
thiserror 2.0 Error type definitions
anyhow 1.0 Error handling
License
[Specify your license here]
Support
For issues and feature requests, please contact:
[Your Team Email]
[Internal Documentation Link]
Changelog
v0.1.0
Initial release
Synology Office integration
Basic Nexrender job generation
Excel parsing with dynamic sheet detection
Multi-variant job creation

28
src/config.rs Normal file
View File

@@ -0,0 +1,28 @@
use anyhow::{Context, Result};
use dotenv::dotenv;
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub nas_fqdn: String,
pub nas_user: String,
pub nas_pass: String,
pub nas_file: String,
pub nexrender_api_url: String,
pub output_folder: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
dotenv().ok();
Ok(Self {
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
nas_pass: env::var("NAS_PASS").context("NAS_PASS not set")?,
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
nexrender_api_url: env::var("NEXRENDER_API_URL").context("NEXRENDER_API_URL not set")?,
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?,
})
}
}

View File

@@ -1,15 +1,16 @@
mod config;
mod nexrender; mod nexrender;
mod synology; mod synology;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use calamine::{Data, Reader, Xlsx}; use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate}; use chrono::{Duration, NaiveDate};
use dotenv::dotenv; use config::Config;
use log::{debug, error, info};
use nexrender::{JobData, LogoRegistry}; use nexrender::{JobData, LogoRegistry};
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::env;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
@@ -17,10 +18,6 @@ use std::time::Duration as StdDuration;
use synology::SynologyClient; use synology::SynologyClient;
use tokio::time::sleep; use tokio::time::sleep;
const NEXRENDER_API: &str = "http://10.10.2.20:3000/api/v1/jobs";
const OUTPUT_FOLDER: &str = "//10.10.35.3/edit/Auto_Anons";
/// Универсальная структура для хранения данных любого листа
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SheetData { pub struct SheetData {
pub name: String, pub name: String,
@@ -70,26 +67,41 @@ impl ExcelWorkbook {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
dotenv().ok(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let nas_fqdn = env::var("NAS_FQDN").context("NAS_FQDN not set")?; info!("Starting AE Anons processor");
let nas_user = env::var("NAS_USER").context("NAS_USER not set")?; let config = Config::from_env()?;
let nas_pass = env::var("NAS_PASS").context("NAS_PASS not set")?; debug!("Configuration loaded: {:?}", config);
let nas_file = env::var("NAS_FILE").context("NAS_FILE not set")?;
let mut client = SynologyClient::new(&nas_fqdn); let mut client = SynologyClient::new(&config.nas_fqdn);
client.login(&nas_user, &nas_pass).await?; client.login(&config.nas_user, &config.nas_pass).await?;
println!("✅ Logged in"); info!("Successfully authenticated with Synology NAS");
let info = client.get_info().await?; let info = client.get_info().await?;
println!("📡 Connected to: {}", info.hostname); info!("Connected to NAS: {}", info.hostname);
let file_name_full = Path::new(&nas_file) let workbook = download_and_parse_excel(&mut client, &config).await?;
display_workbook_structure(&workbook);
save_workbook_json(&workbook, &config.nas_file)?;
process_nexrender_jobs(&workbook, &config).await?;
client.logout().await?;
info!("Session terminated successfully");
Ok(())
}
async fn download_and_parse_excel(
client: &mut SynologyClient,
config: &Config,
) -> Result<ExcelWorkbook> {
let file_name_full = Path::new(&config.nas_file)
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("unknown"); .unwrap_or("unknown");
let search_name = file_name_full.replace(".osheet", ""); let search_name = file_name_full.replace(".osheet", "");
let expected_path = Path::new(&nas_file) let expected_path = Path::new(&config.nas_file)
.parent() .parent()
.and_then(|p| p.to_str()) .and_then(|p| p.to_str())
.unwrap_or(""); .unwrap_or("");
@@ -99,7 +111,7 @@ async fn main() -> Result<()> {
.replace(' ', ""); .replace(' ', "");
let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full); let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full);
println!("\n🔍 Looking for file: {}", full_expected_path_api); info!("Searching for file: {}", full_expected_path_api);
let search_result = client.search_file_by_name(&search_name).await?; let search_result = client.search_file_by_name(&search_name).await?;
let items = search_result["data"]["items"] let items = search_result["data"]["items"]
@@ -132,37 +144,47 @@ async fn main() -> Result<()> {
.as_str() .as_str()
.ok_or_else(|| anyhow!("Missing name"))?; .ok_or_else(|| anyhow!("Missing name"))?;
println!(" 📄 Found: {}", actual_file_name); info!("Found file: {}", actual_file_name);
info!("Exporting file from Synology Office to Excel format...");
println!("\n📥 Exporting file...");
let output_path = format!("./{}_export.xlsx", search_name); let output_path = format!("./{}_export.xlsx", search_name);
let data = client.export_by_file_id(file_id, actual_file_name).await?; let data = client.export_by_file_id(file_id, actual_file_name).await?;
let mut file = File::create(&output_path)?; let mut file = File::create(&output_path)?;
file.write_all(&data)?; file.write_all(&data)?;
println!("Exported to: {} ({} bytes)", output_path, data.len()); info!("Exported to: {} ({} bytes)", output_path, data.len());
println!("\n📖 Parsing Excel file..."); info!("Parsing Excel workbook...");
let workbook = read_excel_workbook(&output_path)?; read_excel_workbook(&output_path)
}
println!("\n📊 Excel Structure:"); fn display_workbook_structure(workbook: &ExcelWorkbook) {
println!(" Total sheets: {}", workbook.sheets.len()); info!("Workbook contains {} sheets", workbook.sheets.len());
for (idx, sheet) in workbook.sheets.iter().enumerate() { for (idx, sheet) in workbook.sheets.iter().enumerate() {
println!("\n Sheet #{}: '{}'", idx + 1, sheet.name); debug!(
println!( "Sheet #{}: '{}' | Headers: {} | Rows: {}",
" Headers ({}): {:?}", idx + 1,
sheet.name,
sheet.headers.len(), sheet.headers.len(),
sheet.headers sheet.rows.len()
); );
println!(" Rows: {}", sheet.rows.len()); if log::log_enabled!(log::Level::Debug) {
debug!(" Headers: {:?}", sheet.headers);
} }
}
}
fn save_workbook_json(workbook: &ExcelWorkbook, nas_file: &str) -> Result<()> {
let search_name = Path::new(nas_file)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.replace(".osheet", "");
// Сохраняем JSON
let json_output = format!("./{}_workbook.json", search_name); let json_output = format!("./{}_workbook.json", search_name);
let json_data = workbook.to_json(); let json_data = workbook.to_json();
std::fs::write(&json_output, serde_json::to_string_pretty(&json_data)?)?; std::fs::write(&json_output, serde_json::to_string_pretty(&json_data)?)?;
println!("\n💾 Full workbook saved to: {}", json_output); info!("Full workbook saved to: {}", json_output);
for sheet in &workbook.sheets { for sheet in &workbook.sheets {
let safe_name = sheet let safe_name = sheet
@@ -175,8 +197,11 @@ async fn main() -> Result<()> {
)?; )?;
} }
// ============ NEXRENDER INTEGRATION ============ Ok(())
println!("\n🎬 Creating Nexrender jobs..."); }
async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Result<()> {
info!("Preparing Nexrender jobs...");
let start_sheet = workbook let start_sheet = workbook
.get_sheet("Start") .get_sheet("Start")
@@ -187,7 +212,7 @@ async fn main() -> Result<()> {
let teams_sheet = workbook let teams_sheet = workbook
.get_sheet("TEAMS") .get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?; .ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
let chanell_sheet = workbook let channel_sheet = workbook
.get_sheet("CHANELL") .get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?; .ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
@@ -196,7 +221,7 @@ async fn main() -> Result<()> {
.iter() .iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect(); .collect();
println!(" 📦 Loaded {} sport packs", packs.len()); info!("Loaded {} sport packs", packs.len());
let mut logos = LogoRegistry::new(); let mut logos = LogoRegistry::new();
for row in &teams_sheet.rows { for row in &teams_sheet.rows {
@@ -206,48 +231,45 @@ async fn main() -> Result<()> {
logos.insert(team.clone(), sport.clone(), link.clone()); logos.insert(team.clone(), sport.clone(), link.clone());
} }
} }
println!(" 🏷️ Loaded {} team logos", teams_sheet.rows.len()); info!("Loaded {} team logos", teams_sheet.rows.len());
// Загружаем логотипы каналов из листа CHANELL let channels: HashMap<String, String> = channel_sheet
let channels: HashMap<String, String> = chanell_sheet
.rows .rows
.iter() .iter()
.filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone()))) .filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone())))
.collect(); .collect();
println!(" 📺 Loaded {} channel logos", channels.len()); info!("Loaded {} channel logos", channels.len());
let mut jobs: Vec<JobData> = Vec::new(); let mut jobs: Vec<JobData> = Vec::new();
for (idx, row) in start_sheet.rows.iter().enumerate() { for (idx, row) in start_sheet.rows.iter().enumerate() {
if let Some(state) = row.get("STATE") { if let Some(state) = row.get("STATE") {
if state == "FALSE" { if state == "FALSE" {
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
println!(" ✅ Job for row {}: {}", idx, job.outfile_name); debug!("Created job for row {}: {}", idx, job.outfile_name);
jobs.extend(job.create_variants()); jobs.extend(job.create_variants());
} }
} }
} }
} }
println!("\n📦 Created {} total jobs (with variants)", jobs.len()); info!("Generated {} total jobs (including variants)", jobs.len());
if jobs.is_empty() { if jobs.is_empty() {
println!(" No jobs with STATE='FALSE' found"); info!("No jobs with STATE='FALSE' found");
client.logout().await?;
println!("\n👋 Logged out");
return Ok(()); return Ok(());
} }
cleanup_finished_jobs().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::new(); let mut submitted_jobs: Vec<(String, String)> = Vec::new();
for job in &jobs { for job in &jobs {
let nexrender_job = job.to_nexrender_job(OUTPUT_FOLDER); let nexrender_job = job.to_nexrender_job(&config.output_folder);
println!(" 📤 Submitting: {}", job.outfile_name); info!("Submitting job: {}", job.outfile_name);
let response = http_client let response = http_client
.post(NEXRENDER_API) .post(&config.nexrender_api_url)
.json(&nexrender_job) .json(&nexrender_job)
.send() .send()
.await?; .await?;
@@ -256,25 +278,25 @@ async fn main() -> Result<()> {
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()) {
submitted_jobs.push((uid.to_string(), job.outfile_name.clone())); submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
println!(" ✅ Submitted, uid: {}", uid); info!("Job submitted successfully, UID: {}", uid);
} }
} 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();
eprintln!("Failed to submit job ({}): {}", status, text); error!("Failed to submit job ({}): {}", status, text);
} }
} }
println!("\n📊 Submitted {} jobs to nexrender", submitted_jobs.len()); info!(
"Successfully submitted {} jobs to Nexrender",
submitted_jobs.len()
);
if !submitted_jobs.is_empty() { if !submitted_jobs.is_empty() {
println!("\nMonitoring job completion..."); info!("Monitoring job completion...");
monitor_jobs(&http_client, submitted_jobs).await?; monitor_jobs(&http_client, &config.nexrender_api_url, submitted_jobs).await?;
} }
client.logout().await?;
println!("\n👋 Done");
Ok(()) Ok(())
} }
@@ -286,7 +308,7 @@ fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook> {
let mut excel_workbook = ExcelWorkbook::default(); let mut excel_workbook = ExcelWorkbook::default();
for sheet_name in sheet_names { for sheet_name in sheet_names {
println!(" 📄 Processing sheet: '{}'", sheet_name); debug!("Processing sheet: '{}'", sheet_name);
let range = workbook let range = workbook
.worksheet_range(&sheet_name) .worksheet_range(&sheet_name)
@@ -420,9 +442,9 @@ fn cell_to_string(cell: &Data) -> String {
} }
} }
async fn cleanup_finished_jobs() -> Result<()> { async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
let client = Client::new(); let client = Client::new();
let response = client.get(NEXRENDER_API).send().await?; let response = client.get(api_url).send().await?;
if response.status().is_success() { if response.status().is_success() {
let jobs: Vec<Value> = response.json().await?; let jobs: Vec<Value> = response.json().await?;
@@ -433,10 +455,10 @@ async fn cleanup_finished_jobs() -> Result<()> {
) { ) {
if state == "finished" || state == "error" { if state == "finished" || state == "error" {
client client
.delete(&format!("{}/{}", NEXRENDER_API, uid)) .delete(&format!("{}/{}", api_url, uid))
.send() .send()
.await?; .await?;
println!(" 🧹 Cleaned up job: {}", uid); info!("Cleaned up completed job: {}", uid);
} }
} }
} }
@@ -444,16 +466,17 @@ async fn cleanup_finished_jobs() -> Result<()> {
Ok(()) Ok(())
} }
async fn monitor_jobs(client: &Client, mut pending: Vec<(String, String)>) -> Result<()> { async fn monitor_jobs(
client: &Client,
api_url: &str,
mut pending: Vec<(String, String)>,
) -> Result<()> {
while !pending.is_empty() { while !pending.is_empty() {
sleep(StdDuration::from_secs(25)).await; sleep(StdDuration::from_secs(25)).await;
let mut remaining = Vec::new(); let mut remaining = Vec::new();
for (uid, outname) in &pending { for (uid, outname) in &pending {
let response = client let response = client.get(&format!("{}/{}", api_url, uid)).send().await?;
.get(&format!("{}/{}", NEXRENDER_API, uid))
.send()
.await?;
if response.status().is_success() { if response.status().is_success() {
let job: Value = response.json().await?; let job: Value = response.json().await?;
@@ -464,9 +487,13 @@ async fn monitor_jobs(client: &Client, mut pending: Vec<(String, String)>) -> Re
match state { match state {
"finished" => { "finished" => {
println!("{} - finished ({} left)", outname, pending.len() - 1) info!(
"Job completed: {} ({} remaining)",
outname,
pending.len() - 1
)
} }
"error" => eprintln!("{} - error ({} left)", outname, pending.len() - 1), "error" => error!("Job failed: {} ({} remaining)", outname, pending.len() - 1),
_ => remaining.push((uid.clone(), outname.clone())), _ => remaining.push((uid.clone(), outname.clone())),
} }
} else { } else {
@@ -475,9 +502,9 @@ async fn monitor_jobs(client: &Client, mut pending: Vec<(String, String)>) -> Re
} }
pending = remaining; pending = remaining;
if !pending.is_empty() { if !pending.is_empty() {
println!("Waiting for {} jobs...", pending.len()); info!("Waiting for {} jobs to complete...", pending.len());
} }
} }
println!("\n🎉 All jobs completed!"); info!("All jobs completed successfully");
Ok(()) Ok(())
} }

View File

@@ -1,3 +1,4 @@
use log;
use reqwest::{ use reqwest::{
multipart::{Form, Part}, multipart::{Form, Part},
Client, ClientBuilder, Client, ClientBuilder,
@@ -457,19 +458,14 @@ impl SynologyClient {
url = format!("{}?{}", url, param_parts.join("&")); url = format!("{}?{}", url, param_parts.join("&"));
} }
let debug = std::env::var("SYNO_DEBUG").unwrap_or_default() == "1"; log::debug!("Request URL: {}", url);
if debug {
eprintln!("[DEBUG] Request URL: {}", url);
}
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
let error_text = response.text().await?; let error_text = response.text().await?;
if debug { log::debug!("Error response: {}", error_text);
eprintln!("[DEBUG] Error response: {}", error_text);
}
return Err(SynologyError::Api { return Err(SynologyError::Api {
code: status.as_u16() as i64, code: status.as_u16() as i64,
message: Some(error_text), message: Some(error_text),
@@ -477,10 +473,7 @@ impl SynologyClient {
} }
let response_text = response.text().await?; let response_text = response.text().await?;
log::debug!("Response: {}", response_text);
if debug {
eprintln!("[DEBUG] Response: {}", response_text);
}
let api_response: ApiResponse<T> = serde_json::from_str(&response_text) let api_response: ApiResponse<T> = serde_json::from_str(&response_text)
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?; .map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
@@ -1252,8 +1245,7 @@ impl SynologyClient {
self.sid.as_ref().unwrap_or(&String::new()) self.sid.as_ref().unwrap_or(&String::new())
); );
println!(" 🔍 Searching for file: {} in path: {}", name, path); log::debug!("Searching for file: {} in path: {}", name, path);
let response = self let response = self
.client .client
.post(&url) .post(&url)
@@ -1287,7 +1279,7 @@ impl SynologyClient {
self.sid.as_ref().unwrap_or(&String::new()) self.sid.as_ref().unwrap_or(&String::new())
); );
println!(" 🔍 Searching for file: {}", name); log::debug!("Searching for file: {}", name);
let response = self let response = self
.client .client
@@ -1325,8 +1317,7 @@ impl SynologyClient {
); );
let url = format!("{}/webapi/{}", self.base_url, endpoint); let url = format!("{}/webapi/{}", self.base_url, endpoint);
println!(" 📤 Export URL: {}", url); log::debug!("Export URL: {}", url);
let response = self let response = self
.client .client
.post(&url) .post(&url)
@@ -1346,8 +1337,7 @@ impl SynologyClient {
} }
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
println!(" ✅ Got {} bytes", bytes.len()); log::debug!("Received {} bytes", bytes.len());
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
} }