Оптимизирована работа с данными листов и улучшена обработка Excel‑дат
Упрощено формирование row_data без лишних переменных. Удалены избыточные отладочные println!, уменьшив шум в консоли. Переписаны комментарии к блокам парсинга, убраны неиспользуемые выводы количества заголовков и строк. Объединено условие проверки Excel‑даты в компактный диапазон (1.0..100000.0). Удалены устаревшие комментарии о базовой дате; оставлено только вычисление даты. Добавлена асинхронная функция cleanup_finished_jobs для очистки завершённых/ошибочных задач Nexrender, с выводом информации о выполненных чистках. Реализована функция monitor_jobs, периодически проверяющая статус ожидающих заданий, выводящая прогресс и финальное сообщение о завершении всех работ. Эти изменения делают код чище, повышают читаемость и добавляют автоматизированное управление задачами Nexrender.
This commit is contained in:
708
Cargo.lock
generated
708
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.13", features = ["json", "multipart", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
csv = "1.3"
|
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
calamine = "0.26"
|
calamine = "0.26"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
regex = "1.11"
|
||||||
|
futures = "0.3"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
||||||
321
src/main.rs
321
src/main.rs
@@ -1,14 +1,24 @@
|
|||||||
|
mod nexrender;
|
||||||
|
mod synology;
|
||||||
|
|
||||||
|
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 dotenv::dotenv;
|
||||||
|
use nexrender::{JobData, LogoRegistry};
|
||||||
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::env;
|
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;
|
||||||
|
use std::time::Duration as StdDuration;
|
||||||
|
use synology::SynologyClient;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use ae_anons::{SynologyClient, SynologyError};
|
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)]
|
||||||
@@ -19,28 +29,6 @@ pub struct SheetData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SheetData {
|
impl SheetData {
|
||||||
/// Получить значение по индексу строки и имени колонки
|
|
||||||
pub fn get(&self, row_idx: usize, col_name: &str) -> Option<&String> {
|
|
||||||
self.rows.get(row_idx).and_then(|row| row.get(col_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить все значения колонки
|
|
||||||
pub fn get_column(&self, col_name: &str) -> Vec<Option<&String>> {
|
|
||||||
self.rows.iter().map(|row| row.get(col_name)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить строку как JSON
|
|
||||||
pub fn row_to_json(&self, row_idx: usize) -> Option<Value> {
|
|
||||||
self.rows.get(row_idx).map(|row| {
|
|
||||||
let map: serde_json::Map<String, Value> = row
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
|
||||||
.collect();
|
|
||||||
Value::Object(map)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Конвертировать весь лист в JSON
|
|
||||||
pub fn to_json(&self) -> Value {
|
pub fn to_json(&self) -> Value {
|
||||||
let rows_json: Vec<Value> = self
|
let rows_json: Vec<Value> = self
|
||||||
.rows
|
.rows
|
||||||
@@ -64,56 +52,38 @@ impl SheetData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Структура для хранения всех листов Excel
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ExcelWorkbook {
|
pub struct ExcelWorkbook {
|
||||||
pub sheets: Vec<SheetData>,
|
pub sheets: Vec<SheetData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExcelWorkbook {
|
impl ExcelWorkbook {
|
||||||
/// Получить лист по имени
|
|
||||||
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
|
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
|
||||||
self.sheets.iter().find(|s| s.name == name)
|
self.sheets.iter().find(|s| s.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить лист по индексу
|
|
||||||
pub fn get_sheet_by_index(&self, index: usize) -> Option<&SheetData> {
|
|
||||||
self.sheets.get(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Конвертировать всю книгу в JSON
|
|
||||||
pub fn to_json(&self) -> Value {
|
pub fn to_json(&self) -> Value {
|
||||||
let sheets_json: Vec<Value> = self.sheets.iter().map(|s| s.to_json()).collect();
|
let sheets_json: Vec<Value> = self.sheets.iter().map(|s| s.to_json()).collect();
|
||||||
serde_json::json!(sheets_json)
|
serde_json::json!(sheets_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить все заголовки всех листов
|
|
||||||
pub fn get_all_headers(&self) -> HashMap<String, Vec<String>> {
|
|
||||||
self.sheets
|
|
||||||
.iter()
|
|
||||||
.map(|sheet| (sheet.name.clone(), sheet.headers.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), SynologyError> {
|
async fn main() -> Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
let nas_fqdn = env::var("NAS_FQDN").expect("❌ Переменная NAS_FQDN не задана в .env");
|
let nas_fqdn = env::var("NAS_FQDN").context("NAS_FQDN not set")?;
|
||||||
let nas_user = env::var("NAS_USER").expect("❌ Переменная NAS_USER не задана в .env");
|
let nas_user = env::var("NAS_USER").context("NAS_USER not set")?;
|
||||||
let nas_pass = env::var("NAS_PASS").expect("❌ Переменная NAS_PASS не задана в .env");
|
let nas_pass = env::var("NAS_PASS").context("NAS_PASS not set")?;
|
||||||
let nas_file = env::var("NAS_FILE").expect("❌ Переменная NAS_FILE не задана в .env");
|
let nas_file = env::var("NAS_FILE").context("NAS_FILE not set")?;
|
||||||
|
|
||||||
let mut client = SynologyClient::new(&nas_fqdn);
|
let mut client = SynologyClient::new(&nas_fqdn);
|
||||||
|
|
||||||
client.login(&nas_user, &nas_pass).await?;
|
client.login(&nas_user, &nas_pass).await?;
|
||||||
println!("✅ Logged in");
|
println!("✅ Logged in");
|
||||||
|
|
||||||
let info = client.get_info().await?;
|
let info = client.get_info().await?;
|
||||||
println!("📡 Connected to: {}", info.hostname);
|
println!("📡 Connected to: {}", info.hostname);
|
||||||
|
|
||||||
// Получаем имя файла
|
|
||||||
let file_name_full = Path::new(&nas_file)
|
let file_name_full = Path::new(&nas_file)
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
@@ -129,20 +99,15 @@ async fn main() -> Result<(), SynologyError> {
|
|||||||
.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);
|
println!("\n🔍 Looking 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"].as_array().ok_or_else(|| {
|
let items = search_result["data"]["items"]
|
||||||
SynologyError::InvalidResponseFormat("No items in search result".to_string())
|
.as_array()
|
||||||
})?;
|
.ok_or_else(|| anyhow!("No items in search result"))?;
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(SynologyError::Api {
|
return Err(anyhow!("File '{}' not found", file_name_full));
|
||||||
code: 404,
|
|
||||||
message: Some(format!("File '{}' not found", file_name_full)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let exact_match: Vec<&Value> = items
|
let exact_match: Vec<&Value> = items
|
||||||
@@ -156,23 +121,19 @@ async fn main() -> Result<(), SynologyError> {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if exact_match.is_empty() {
|
if exact_match.is_empty() {
|
||||||
return Err(SynologyError::Api {
|
return Err(anyhow!("File not found at: {}", full_expected_path_api));
|
||||||
code: 400,
|
|
||||||
message: Some(format!("File not found at: {}", full_expected_path_api)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = exact_match[0];
|
let file = exact_match[0];
|
||||||
let file_id = file["file_id"]
|
let file_id = file["file_id"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| SynologyError::InvalidResponseFormat("Missing file_id".to_string()))?;
|
.ok_or_else(|| anyhow!("Missing file_id"))?;
|
||||||
let actual_file_name = file["name"]
|
let actual_file_name = file["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| SynologyError::InvalidResponseFormat("Missing name".to_string()))?;
|
.ok_or_else(|| anyhow!("Missing name"))?;
|
||||||
|
|
||||||
println!(" 📄 Found: {}", actual_file_name);
|
println!(" 📄 Found: {}", actual_file_name);
|
||||||
|
|
||||||
// Экспортируем файл
|
|
||||||
println!("\n📥 Exporting file...");
|
println!("\n📥 Exporting file...");
|
||||||
let output_path = format!("./{}_export.xlsx", search_name);
|
let output_path = format!("./{}_export.xlsx", search_name);
|
||||||
|
|
||||||
@@ -181,11 +142,9 @@ async fn main() -> Result<(), SynologyError> {
|
|||||||
file.write_all(&data)?;
|
file.write_all(&data)?;
|
||||||
println!(" ✅ Exported to: {} ({} bytes)", output_path, data.len());
|
println!(" ✅ Exported to: {} ({} bytes)", output_path, data.len());
|
||||||
|
|
||||||
// Читаем Excel и получаем структурированные данные
|
|
||||||
println!("\n📖 Parsing Excel file...");
|
println!("\n📖 Parsing Excel file...");
|
||||||
let workbook = read_excel_workbook(&output_path)?;
|
let workbook = read_excel_workbook(&output_path)?;
|
||||||
|
|
||||||
// Выводим информацию о структуре
|
|
||||||
println!("\n📊 Excel Structure:");
|
println!("\n📊 Excel Structure:");
|
||||||
println!(" Total sheets: {}", workbook.sheets.len());
|
println!(" Total sheets: {}", workbook.sheets.len());
|
||||||
|
|
||||||
@@ -197,50 +156,131 @@ async fn main() -> Result<(), SynologyError> {
|
|||||||
sheet.headers
|
sheet.headers
|
||||||
);
|
);
|
||||||
println!(" Rows: {}", sheet.rows.len());
|
println!(" Rows: {}", sheet.rows.len());
|
||||||
|
|
||||||
// Показываем пример данных из первой строки
|
|
||||||
if let Some(first_row) = sheet.rows.first() {
|
|
||||||
println!(" First row sample:");
|
|
||||||
for (key, value) in first_row.iter().take(5) {
|
|
||||||
println!(" {} = {}", key, value);
|
|
||||||
}
|
|
||||||
if first_row.len() > 5 {
|
|
||||||
println!(" ... and {} more fields", first_row.len() - 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем всю книгу в JSON
|
// Сохраняем 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();
|
||||||
let json_str = serde_json::to_string_pretty(&json_data)
|
std::fs::write(&json_output, serde_json::to_string_pretty(&json_data)?)?;
|
||||||
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
|
|
||||||
std::fs::write(&json_output, json_str)?;
|
|
||||||
println!("\n💾 Full workbook saved to: {}", json_output);
|
println!("\n💾 Full workbook saved to: {}", json_output);
|
||||||
|
|
||||||
// Сохраняем каждый лист отдельно
|
|
||||||
for sheet in &workbook.sheets {
|
for sheet in &workbook.sheets {
|
||||||
let safe_name = sheet
|
let safe_name = sheet
|
||||||
.name
|
.name
|
||||||
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
|
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
|
||||||
let sheet_json_path = format!("./{}_{}.json", search_name, safe_name);
|
let sheet_json_path = format!("./{}_{}.json", search_name, safe_name);
|
||||||
let sheet_json = sheet.to_json();
|
std::fs::write(
|
||||||
let sheet_json_str = serde_json::to_string_pretty(&sheet_json)
|
&sheet_json_path,
|
||||||
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
|
serde_json::to_string_pretty(&sheet.to_json())?,
|
||||||
std::fs::write(&sheet_json_path, sheet_json_str)?;
|
)?;
|
||||||
println!("💾 Sheet '{}' saved to: {}", sheet.name, sheet_json_path);
|
}
|
||||||
|
|
||||||
|
// ============ NEXRENDER INTEGRATION ============
|
||||||
|
println!("\n🎬 Creating 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 chanell_sheet = workbook
|
||||||
|
.get_sheet("CHANELL")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
||||||
|
|
||||||
|
let packs: HashMap<String, String> = sport_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
|
||||||
|
.collect();
|
||||||
|
println!(" 📦 Loaded {} sport packs", packs.len());
|
||||||
|
|
||||||
|
let mut logos = LogoRegistry::new();
|
||||||
|
for row in &teams_sheet.rows {
|
||||||
|
if let (Some(team), Some(sport), Some(link)) =
|
||||||
|
(row.get("TEAM"), row.get("SPORT"), row.get("LINK"))
|
||||||
|
{
|
||||||
|
logos.insert(team.clone(), sport.clone(), link.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" 🏷️ Loaded {} team logos", teams_sheet.rows.len());
|
||||||
|
|
||||||
|
// Загружаем логотипы каналов из листа CHANELL
|
||||||
|
let channels: HashMap<String, String> = chanell_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone())))
|
||||||
|
.collect();
|
||||||
|
println!(" 📺 Loaded {} channel logos", channels.len());
|
||||||
|
|
||||||
|
let mut jobs: Vec<JobData> = Vec::new();
|
||||||
|
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
||||||
|
if let Some(state) = row.get("STATE") {
|
||||||
|
if state == "FALSE" {
|
||||||
|
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
|
||||||
|
println!(" ✅ Job for row {}: {}", idx, job.outfile_name);
|
||||||
|
jobs.extend(job.create_variants());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n📦 Created {} total jobs (with variants)", jobs.len());
|
||||||
|
|
||||||
|
if jobs.is_empty() {
|
||||||
|
println!(" ℹ️ No jobs with STATE='FALSE' found");
|
||||||
|
client.logout().await?;
|
||||||
|
println!("\n👋 Logged out");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_finished_jobs().await?;
|
||||||
|
|
||||||
|
let http_client = Client::new();
|
||||||
|
let mut submitted_jobs: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
for job in &jobs {
|
||||||
|
let nexrender_job = job.to_nexrender_job(OUTPUT_FOLDER);
|
||||||
|
println!(" 📤 Submitting: {}", job.outfile_name);
|
||||||
|
|
||||||
|
let response = http_client
|
||||||
|
.post(NEXRENDER_API)
|
||||||
|
.json(&nexrender_job)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let result: Value = response.json().await?;
|
||||||
|
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
||||||
|
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
|
||||||
|
println!(" ✅ Submitted, uid: {}", uid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
eprintln!(" ❌ Failed to submit job ({}): {}", status, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n📊 Submitted {} jobs to nexrender", submitted_jobs.len());
|
||||||
|
|
||||||
|
if !submitted_jobs.is_empty() {
|
||||||
|
println!("\n⏳ Monitoring job completion...");
|
||||||
|
monitor_jobs(&http_client, submitted_jobs).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.logout().await?;
|
client.logout().await?;
|
||||||
println!("\n👋 Logged out");
|
println!("\n👋 Done");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Читает Excel файл и возвращает структурированную книгу
|
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook> {
|
||||||
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError> {
|
|
||||||
let mut workbook: Xlsx<_> = calamine::open_workbook(file_path)
|
let mut workbook: Xlsx<_> = calamine::open_workbook(file_path)
|
||||||
.map_err(|e| SynologyError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
.map_err(|e| anyhow!("Failed to open workbook: {}", e))?;
|
||||||
|
|
||||||
let sheet_names = workbook.sheet_names().to_vec();
|
let sheet_names = workbook.sheet_names().to_vec();
|
||||||
let mut excel_workbook = ExcelWorkbook::default();
|
let mut excel_workbook = ExcelWorkbook::default();
|
||||||
@@ -248,13 +288,9 @@ fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError>
|
|||||||
for sheet_name in sheet_names {
|
for sheet_name in sheet_names {
|
||||||
println!(" 📄 Processing sheet: '{}'", sheet_name);
|
println!(" 📄 Processing sheet: '{}'", sheet_name);
|
||||||
|
|
||||||
let range = match workbook.worksheet_range(&sheet_name) {
|
let range = workbook
|
||||||
Ok(range) => range,
|
.worksheet_range(&sheet_name)
|
||||||
Err(e) => {
|
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
|
||||||
println!(" ⚠️ Error reading sheet: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let sheet_data = parse_sheet_dynamic(&sheet_name, &range)?;
|
let sheet_data = parse_sheet_dynamic(&sheet_name, &range)?;
|
||||||
excel_workbook.sheets.push(sheet_data);
|
excel_workbook.sheets.push(sheet_data);
|
||||||
@@ -263,12 +299,7 @@ fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError>
|
|||||||
Ok(excel_workbook)
|
Ok(excel_workbook)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Парсит лист в динамическую структуру
|
fn parse_sheet_dynamic(sheet_name: &str, range: &calamine::Range<Data>) -> Result<SheetData> {
|
||||||
fn parse_sheet_dynamic(
|
|
||||||
sheet_name: &str,
|
|
||||||
range: &calamine::Range<Data>,
|
|
||||||
) -> Result<SheetData, SynologyError> {
|
|
||||||
// Собираем все ячейки в структуру
|
|
||||||
let mut cell_map: HashMap<(usize, usize), String> = HashMap::new();
|
let mut cell_map: HashMap<(usize, usize), String> = HashMap::new();
|
||||||
let mut max_row = 0usize;
|
let mut max_row = 0usize;
|
||||||
let mut max_col = 0usize;
|
let mut max_col = 0usize;
|
||||||
@@ -290,19 +321,15 @@ fn parse_sheet_dynamic(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем матрицу данных
|
|
||||||
let mut data_matrix: Vec<Vec<String>> = Vec::new();
|
let mut data_matrix: Vec<Vec<String>> = Vec::new();
|
||||||
|
|
||||||
for row in 0..=max_row {
|
for row in 0..=max_row {
|
||||||
let mut row_data = Vec::new();
|
let mut row_data = Vec::new();
|
||||||
for col in 0..=max_col {
|
for col in 0..=max_col {
|
||||||
let value = cell_map.get(&(row, col)).cloned().unwrap_or_default();
|
row_data.push(cell_map.get(&(row, col)).cloned().unwrap_or_default());
|
||||||
row_data.push(value);
|
|
||||||
}
|
}
|
||||||
data_matrix.push(row_data);
|
data_matrix.push(row_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Первая строка - заголовки
|
|
||||||
let headers: Vec<String> = if !data_matrix.is_empty() {
|
let headers: Vec<String> = if !data_matrix.is_empty() {
|
||||||
data_matrix[0]
|
data_matrix[0]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -320,32 +347,22 @@ fn parse_sheet_dynamic(
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" Found {} headers: {:?}", headers.len(), headers);
|
|
||||||
|
|
||||||
let mut rows_data = Vec::new();
|
let mut rows_data = Vec::new();
|
||||||
|
|
||||||
// Остальные строки - данные
|
|
||||||
for row_values in data_matrix.iter().skip(1) {
|
for row_values in data_matrix.iter().skip(1) {
|
||||||
let mut row_map = HashMap::new();
|
let mut row_map = HashMap::new();
|
||||||
|
|
||||||
for (col_idx, value) in row_values.iter().enumerate() {
|
for (col_idx, value) in row_values.iter().enumerate() {
|
||||||
if col_idx < headers.len() && !value.is_empty() {
|
if col_idx < headers.len() && !value.is_empty() {
|
||||||
row_map.insert(headers[col_idx].clone(), value.clone());
|
row_map.insert(headers[col_idx].clone(), value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !row_map.is_empty() {
|
if !row_map.is_empty() {
|
||||||
rows_data.push(row_map);
|
rows_data.push(row_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows_data.len() >= 10000 {
|
if rows_data.len() >= 10000 {
|
||||||
println!(" ... truncated at 10000 rows");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" Loaded {} data rows", rows_data.len());
|
|
||||||
|
|
||||||
Ok(SheetData {
|
Ok(SheetData {
|
||||||
name: sheet_name.to_string(),
|
name: sheet_name.to_string(),
|
||||||
headers,
|
headers,
|
||||||
@@ -353,12 +370,9 @@ fn parse_sheet_dynamic(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Преобразует Excel серийный номер даты в строку формата DD.MM.YYYY
|
|
||||||
fn excel_date_to_string(serial: f64) -> String {
|
fn excel_date_to_string(serial: f64) -> String {
|
||||||
let days = serial as i64;
|
let days = serial as i64;
|
||||||
// Excel использует 30.12.1899 как базовую дату
|
|
||||||
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
||||||
|
|
||||||
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
||||||
date.format("%d.%m.%Y").to_string()
|
date.format("%d.%m.%Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -366,13 +380,10 @@ fn excel_date_to_string(serial: f64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет, является ли число датой Excel
|
|
||||||
fn is_excel_date(value: f64) -> bool {
|
fn is_excel_date(value: f64) -> bool {
|
||||||
// Excel даты обычно в диапазоне от 1 до 100000
|
(1.0..100000.0).contains(&value)
|
||||||
value > 1.0 && value < 100000.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Преобразует ячейку в строку с конвертацией дат
|
|
||||||
fn cell_to_string(cell: &Data) -> String {
|
fn cell_to_string(cell: &Data) -> String {
|
||||||
match cell {
|
match cell {
|
||||||
Data::Empty => String::new(),
|
Data::Empty => String::new(),
|
||||||
@@ -408,3 +419,65 @@ fn cell_to_string(cell: &Data) -> String {
|
|||||||
Data::Error(e) => format!("{:?}", e),
|
Data::Error(e) => format!("{:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup_finished_jobs() -> Result<()> {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client.get(NEXRENDER_API).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let jobs: Vec<Value> = response.json().await?;
|
||||||
|
for job in jobs {
|
||||||
|
if let (Some(uid), Some(state)) = (
|
||||||
|
job.get("uid").and_then(|u| u.as_str()),
|
||||||
|
job.get("state").and_then(|s| s.as_str()),
|
||||||
|
) {
|
||||||
|
if state == "finished" || state == "error" {
|
||||||
|
client
|
||||||
|
.delete(&format!("{}/{}", NEXRENDER_API, uid))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
println!(" 🧹 Cleaned up job: {}", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn monitor_jobs(client: &Client, mut pending: Vec<(String, String)>) -> Result<()> {
|
||||||
|
while !pending.is_empty() {
|
||||||
|
sleep(StdDuration::from_secs(25)).await;
|
||||||
|
|
||||||
|
let mut remaining = Vec::new();
|
||||||
|
for (uid, outname) in &pending {
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/{}", NEXRENDER_API, uid))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let job: Value = response.json().await?;
|
||||||
|
let state = job
|
||||||
|
.get("state")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
match state {
|
||||||
|
"finished" => {
|
||||||
|
println!(" ✅ {} - finished ({} left)", outname, pending.len() - 1)
|
||||||
|
}
|
||||||
|
"error" => eprintln!(" ❌ {} - error ({} left)", outname, pending.len() - 1),
|
||||||
|
_ => remaining.push((uid.clone(), outname.clone())),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remaining.push((uid.clone(), outname.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = remaining;
|
||||||
|
if !pending.is_empty() {
|
||||||
|
println!(" ⏳ Waiting for {} jobs...", pending.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("\n🎉 All jobs completed!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
586
src/nexrender.rs
Normal file
586
src/nexrender.rs
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn transliterate(text: &str, _reversed: bool) -> String {
|
||||||
|
let mapping = [
|
||||||
|
("а", "a"),
|
||||||
|
("б", "b"),
|
||||||
|
("в", "v"),
|
||||||
|
("г", "g"),
|
||||||
|
("д", "d"),
|
||||||
|
("е", "e"),
|
||||||
|
("ё", "yo"),
|
||||||
|
("ж", "zh"),
|
||||||
|
("з", "z"),
|
||||||
|
("и", "i"),
|
||||||
|
("й", "y"),
|
||||||
|
("к", "k"),
|
||||||
|
("л", "l"),
|
||||||
|
("м", "m"),
|
||||||
|
("н", "n"),
|
||||||
|
("о", "o"),
|
||||||
|
("п", "p"),
|
||||||
|
("р", "r"),
|
||||||
|
("с", "s"),
|
||||||
|
("т", "t"),
|
||||||
|
("у", "u"),
|
||||||
|
("ф", "f"),
|
||||||
|
("х", "h"),
|
||||||
|
("ц", "ts"),
|
||||||
|
("ч", "ch"),
|
||||||
|
("ш", "sh"),
|
||||||
|
("щ", "sch"),
|
||||||
|
("ъ", ""),
|
||||||
|
("ы", "y"),
|
||||||
|
("ь", ""),
|
||||||
|
("э", "e"),
|
||||||
|
("ю", "yu"),
|
||||||
|
("я", "ya"),
|
||||||
|
("А", "A"),
|
||||||
|
("Б", "B"),
|
||||||
|
("В", "V"),
|
||||||
|
("Г", "G"),
|
||||||
|
("Д", "D"),
|
||||||
|
("Е", "E"),
|
||||||
|
("Ё", "Yo"),
|
||||||
|
("Ж", "Zh"),
|
||||||
|
("З", "Z"),
|
||||||
|
("И", "I"),
|
||||||
|
("Й", "Y"),
|
||||||
|
("К", "K"),
|
||||||
|
("Л", "L"),
|
||||||
|
("М", "M"),
|
||||||
|
("Н", "N"),
|
||||||
|
("О", "O"),
|
||||||
|
("П", "P"),
|
||||||
|
("Р", "R"),
|
||||||
|
("С", "S"),
|
||||||
|
("Т", "T"),
|
||||||
|
("У", "U"),
|
||||||
|
("Ф", "F"),
|
||||||
|
("Х", "H"),
|
||||||
|
("Ц", "Ts"),
|
||||||
|
("Ч", "Ch"),
|
||||||
|
("Ш", "Sh"),
|
||||||
|
("Щ", "Sch"),
|
||||||
|
("Ъ", ""),
|
||||||
|
("Ы", "Y"),
|
||||||
|
("Ь", ""),
|
||||||
|
("Э", "E"),
|
||||||
|
("Ю", "Yu"),
|
||||||
|
("Я", "Ya"),
|
||||||
|
];
|
||||||
|
let mut result = text.to_string();
|
||||||
|
for (cyr, lat) in mapping {
|
||||||
|
result = result.replace(cyr, lat);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Template {
|
||||||
|
pub src: String,
|
||||||
|
pub composition: String,
|
||||||
|
#[serde(rename = "outputModule")]
|
||||||
|
pub output_module: String,
|
||||||
|
#[serde(rename = "outputExt")]
|
||||||
|
pub output_ext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Template {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_DOUBLE.aepx".to_string(),
|
||||||
|
composition: "pack".to_string(),
|
||||||
|
output_module: "Start_h264".to_string(),
|
||||||
|
output_ext: "mp4".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template {
|
||||||
|
pub fn single() -> Self {
|
||||||
|
Self {
|
||||||
|
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_SINGLE.aepx".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "module")]
|
||||||
|
pub enum PostrenderAction {
|
||||||
|
#[serde(rename = "@nexrender/action-encode")]
|
||||||
|
Encode { preset: String, output: String },
|
||||||
|
#[serde(rename = "@nexrender/action-copy")]
|
||||||
|
Copy { input: String, output: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Asset {
|
||||||
|
Data {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
asset_type: String,
|
||||||
|
#[serde(rename = "layerName")]
|
||||||
|
layer_name: String,
|
||||||
|
property: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
},
|
||||||
|
DataExpression {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
asset_type: String,
|
||||||
|
#[serde(rename = "layerName")]
|
||||||
|
layer_name: String,
|
||||||
|
property: String,
|
||||||
|
expression: String,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
src: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
asset_type: String,
|
||||||
|
#[serde(rename = "layerName")]
|
||||||
|
layer_name: String,
|
||||||
|
},
|
||||||
|
Video {
|
||||||
|
src: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
asset_type: String,
|
||||||
|
#[serde(rename = "layerName")]
|
||||||
|
layer_name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct NexrenderJob {
|
||||||
|
pub template: Template,
|
||||||
|
pub actions: Actions,
|
||||||
|
pub assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Actions {
|
||||||
|
pub postrender: Vec<PostrenderAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Actions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
postrender: vec![PostrenderAction::Encode {
|
||||||
|
preset: "mp4".to_string(),
|
||||||
|
output: "encoded.mp4".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JobData {
|
||||||
|
pub date: String,
|
||||||
|
pub time: String,
|
||||||
|
pub time_h: String,
|
||||||
|
pub time_m: String,
|
||||||
|
pub channel: String,
|
||||||
|
pub sport: String,
|
||||||
|
pub league: String,
|
||||||
|
pub team_a: String,
|
||||||
|
pub team_b: String,
|
||||||
|
pub data_display: String,
|
||||||
|
pub pack_path: String,
|
||||||
|
pub team_a_logo: String,
|
||||||
|
pub team_b_logo: String,
|
||||||
|
pub team_a_logo_res: Option<String>,
|
||||||
|
pub team_b_logo_res: Option<String>,
|
||||||
|
pub channel_logo: String,
|
||||||
|
pub outfile_name: String,
|
||||||
|
pub row_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobData {
|
||||||
|
pub fn from_row(
|
||||||
|
row: &HashMap<String, String>,
|
||||||
|
row_index: usize,
|
||||||
|
packs: &HashMap<String, String>,
|
||||||
|
logos: &LogoRegistry,
|
||||||
|
channels: &HashMap<String, String>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let state = row.get("STATE").map(|s| s.as_str()).unwrap_or("");
|
||||||
|
if state != "FALSE" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sport = row.get("SPORT")?.clone();
|
||||||
|
let league = row.get("LEAGUE")?.clone();
|
||||||
|
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||||||
|
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||||||
|
let channel = row.get("CHANEL").cloned().unwrap_or_default();
|
||||||
|
let time = row.get("TIME")?.clone();
|
||||||
|
let data_str = row.get("DATA")?.clone();
|
||||||
|
|
||||||
|
let (date_obj, data_display) = parse_date(&data_str);
|
||||||
|
let (time_h, time_m) = parse_time(&time);
|
||||||
|
|
||||||
|
let pack_path = packs.get(&sport).cloned().unwrap_or_default();
|
||||||
|
let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
let (team_a_name, team_a_res) = parse_team_name(&team_a);
|
||||||
|
let (team_b_name, team_b_res) = parse_team_name(&team_b);
|
||||||
|
|
||||||
|
let team_a_logo = logos.find(&team_a, &sport).unwrap_or_default();
|
||||||
|
let team_b_logo = logos.find(&team_b, &sport).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut fn_parts = Vec::new();
|
||||||
|
fn_parts.push(format!(
|
||||||
|
"{:04}{:02}{:02}",
|
||||||
|
date_obj.year(),
|
||||||
|
date_obj.month(),
|
||||||
|
date_obj.day()
|
||||||
|
));
|
||||||
|
if !sport.is_empty() && sport != "Без оформления" {
|
||||||
|
fn_parts.push(sport.clone());
|
||||||
|
}
|
||||||
|
fn_parts.push(league.trim_end_matches('.').to_string());
|
||||||
|
if !team_a_name.is_empty() {
|
||||||
|
fn_parts.push(team_a_name.clone());
|
||||||
|
}
|
||||||
|
if !team_b_name.is_empty() {
|
||||||
|
fn_parts.push(team_b_name.clone());
|
||||||
|
}
|
||||||
|
if !channel.is_empty() {
|
||||||
|
fn_parts.push(channel.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let outfile_name = transliterate(&fn_parts.join("_"), true)
|
||||||
|
.replace(' ', "-")
|
||||||
|
.replace('\'', "");
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
date: data_str,
|
||||||
|
time: time.clone(),
|
||||||
|
time_h,
|
||||||
|
time_m,
|
||||||
|
channel,
|
||||||
|
sport,
|
||||||
|
league,
|
||||||
|
team_a: team_a_name,
|
||||||
|
team_b: team_b_name,
|
||||||
|
data_display,
|
||||||
|
pack_path: unc_to_uri(&pack_path),
|
||||||
|
team_a_logo: unc_to_uri(&team_a_logo),
|
||||||
|
team_b_logo: unc_to_uri(&team_b_logo),
|
||||||
|
team_a_logo_res: team_a_res,
|
||||||
|
team_b_logo_res: team_b_res,
|
||||||
|
channel_logo: unc_to_uri(&channel_logo),
|
||||||
|
outfile_name,
|
||||||
|
row_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_variants(&self) -> Vec<Self> {
|
||||||
|
let mut variants = vec![self.clone()];
|
||||||
|
let mut today = self.clone();
|
||||||
|
today.data_display = "сегодня".to_string();
|
||||||
|
today.outfile_name = format!("{}_Today", self.outfile_name);
|
||||||
|
variants.push(today);
|
||||||
|
let mut tomorrow = self.clone();
|
||||||
|
tomorrow.data_display = "завтра".to_string();
|
||||||
|
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
|
||||||
|
variants.push(tomorrow);
|
||||||
|
variants
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_nexrender_job(&self, output_folder: &str) -> NexrenderJob {
|
||||||
|
let template = if self.team_b.is_empty() {
|
||||||
|
Template::single()
|
||||||
|
} else {
|
||||||
|
Template::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_path = format!("{}/{}.mp4", output_folder, self.outfile_name);
|
||||||
|
let mut actions = Actions::default();
|
||||||
|
actions.postrender.push(PostrenderAction::Copy {
|
||||||
|
input: "encoded.mp4".to_string(),
|
||||||
|
output: output_path,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut assets = Vec::new();
|
||||||
|
self.add_text_assets(&mut assets);
|
||||||
|
self.add_image_assets(&mut assets);
|
||||||
|
if !self.pack_path.is_empty() {
|
||||||
|
assets.push(Asset::Video {
|
||||||
|
src: self.pack_path.clone(),
|
||||||
|
asset_type: "video".to_string(),
|
||||||
|
layer_name: "TOP".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
NexrenderJob {
|
||||||
|
template,
|
||||||
|
actions,
|
||||||
|
assets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_text_assets(&self, assets: &mut Vec<Asset>) {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "DATA".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.data_display),
|
||||||
|
});
|
||||||
|
self.add_date_adjustments(assets);
|
||||||
|
|
||||||
|
if !self.team_b.is_empty() {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TIME_H".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.time_h),
|
||||||
|
});
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TIME_M".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.time_m),
|
||||||
|
});
|
||||||
|
self.add_time_adjustments(assets);
|
||||||
|
} else {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TIME".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.time),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "LEAGUE".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.league),
|
||||||
|
});
|
||||||
|
self.add_league_adjustments(assets);
|
||||||
|
|
||||||
|
if !self.sport.is_empty() {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "SPORT".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(self.sport),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() {
|
||||||
|
format!("{} - {}", self.team_a, self.team_b)
|
||||||
|
} else if !self.team_a.is_empty() {
|
||||||
|
self.team_a.clone()
|
||||||
|
} else {
|
||||||
|
self.team_b.clone()
|
||||||
|
};
|
||||||
|
if !teams_text.is_empty() {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TEAMS".to_string(),
|
||||||
|
property: "Source Text".to_string(),
|
||||||
|
value: json!(teams_text),
|
||||||
|
});
|
||||||
|
self.add_teams_adjustments(assets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_date_adjustments(&self, assets: &mut Vec<Asset>) {
|
||||||
|
let (font_size, anchor_point): (&str, Vec<i32>) = match self.data_display.as_str() {
|
||||||
|
"сегодня" if !self.team_b.is_empty() => ("105", vec![0, 5]),
|
||||||
|
"завтра" if !self.team_b.is_empty() => ("115", vec![0, 25]),
|
||||||
|
_ if self.data_display.len() < 6 && !self.team_b.is_empty() => ("120", vec![0, 20]),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "DATA".to_string(),
|
||||||
|
property: "Source Text.fontSize".to_string(),
|
||||||
|
value: json!(font_size),
|
||||||
|
});
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "DATA".to_string(),
|
||||||
|
property: "transform.anchorPoint".to_string(),
|
||||||
|
value: json!(anchor_point),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_time_adjustments(&self, assets: &mut Vec<Asset>) {
|
||||||
|
let anchor_point = if self.time_h.len() < 2 {
|
||||||
|
vec![60, 0]
|
||||||
|
} else if self.time_h.len() == 2 && self.time_h.parse::<i32>().unwrap_or(20) < 20 {
|
||||||
|
vec![20, 0]
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for layer in ["TIME_H", "TIME_M", "TIME"] {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: layer.to_string(),
|
||||||
|
property: "transform.anchorPoint".to_string(),
|
||||||
|
value: json!(anchor_point),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_league_adjustments(&self, assets: &mut Vec<Asset>) {
|
||||||
|
if self.league.len() > 16 {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "LEAGUE".to_string(),
|
||||||
|
property: "Source Text.fontSize".to_string(),
|
||||||
|
value: json!("73"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_teams_adjustments(&self, assets: &mut Vec<Asset>) {
|
||||||
|
if (self.team_a.len() + self.team_b.len()) >= 32 {
|
||||||
|
assets.push(Asset::Data {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TEAMS".to_string(),
|
||||||
|
property: "Source Text.fontSize".to_string(),
|
||||||
|
value: json!("55"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_image_assets(&self, assets: &mut Vec<Asset>) {
|
||||||
|
if !self.team_a_logo.is_empty() {
|
||||||
|
assets.push(Asset::Image {
|
||||||
|
src: self.team_a_logo.clone(),
|
||||||
|
asset_type: "image".to_string(),
|
||||||
|
layer_name: "TEAM_A_LOGO".to_string(),
|
||||||
|
});
|
||||||
|
if let Some(res) = &self.team_a_logo_res {
|
||||||
|
assets.push(Asset::DataExpression {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TEAM_A_LOGO".to_string(),
|
||||||
|
property: "scale".to_string(),
|
||||||
|
expression: logo_scale_expression(res),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.team_b_logo.is_empty() {
|
||||||
|
assets.push(Asset::Image {
|
||||||
|
src: self.team_b_logo.clone(),
|
||||||
|
asset_type: "image".to_string(),
|
||||||
|
layer_name: "TEAM_B_LOGO".to_string(),
|
||||||
|
});
|
||||||
|
if let Some(res) = &self.team_b_logo_res {
|
||||||
|
assets.push(Asset::DataExpression {
|
||||||
|
asset_type: "data".to_string(),
|
||||||
|
layer_name: "TEAM_B_LOGO".to_string(),
|
||||||
|
property: "scale".to_string(),
|
||||||
|
expression: logo_scale_expression(res),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.channel_logo.is_empty() {
|
||||||
|
assets.push(Asset::Image {
|
||||||
|
src: self.channel_logo.clone(),
|
||||||
|
asset_type: "image".to_string(),
|
||||||
|
layer_name: "CHANELL".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LogoRegistry {
|
||||||
|
logos: HashMap<(String, String), String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogoRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, team: String, sport: String, link: String) {
|
||||||
|
self.logos.insert((team, sport), link);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
||||||
|
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
||||||
|
return Some(link.clone());
|
||||||
|
}
|
||||||
|
for ((t, s), link) in &self.logos {
|
||||||
|
if s == sport && team.starts_with(t) {
|
||||||
|
return Some(link.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_date(s: &str) -> (NaiveDate, String) {
|
||||||
|
let parts: Vec<&str> = s.split('.').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
if let (Ok(day), Ok(month), Ok(year)) = (
|
||||||
|
parts[0].parse::<u32>(),
|
||||||
|
parts[1].parse::<u32>(),
|
||||||
|
parts[2].parse::<i32>(),
|
||||||
|
) {
|
||||||
|
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
|
||||||
|
let months = [
|
||||||
|
"",
|
||||||
|
"января",
|
||||||
|
"февраля",
|
||||||
|
"марта",
|
||||||
|
"апреля",
|
||||||
|
"мая",
|
||||||
|
"июня",
|
||||||
|
"июля",
|
||||||
|
"августа",
|
||||||
|
"сентября",
|
||||||
|
"октября",
|
||||||
|
"ноября",
|
||||||
|
"декабря",
|
||||||
|
];
|
||||||
|
return (date, format!("{} {}", day, months[month as usize]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_time(s: &str) -> (String, String) {
|
||||||
|
let parts: Vec<&str> = s.split(':').collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
(parts[0].to_string(), parts[1].to_string())
|
||||||
|
} else {
|
||||||
|
(s.to_string(), "00".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_team_name(s: &str) -> (String, Option<String>) {
|
||||||
|
let parts: Vec<&str> = s.split('#').collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
(parts[0].to_string(), Some(parts[2].to_string()))
|
||||||
|
} else {
|
||||||
|
(parts[0].to_string(), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unc_to_uri(path: &str) -> String {
|
||||||
|
if path.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
format!("file://{}", path.replace('\\', "/").trim_start_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logo_scale_expression(target_size: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
||||||
|
target_size
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use reqwest::{
|
use reqwest::{
|
||||||
Client, ClientBuilder,
|
|
||||||
multipart::{Form, Part},
|
multipart::{Form, Part},
|
||||||
|
Client, ClientBuilder,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -37,7 +37,7 @@ pub enum SynologyError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
#[error("CSV error: {0}")]
|
#[error("CSV error: {0}")]
|
||||||
Csv(#[from] csv::Error),
|
Csv(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Результат операций
|
/// Результат операций
|
||||||
Reference in New Issue
Block a user