Оптимизирована работа с данными листов и улучшена обработка Excel‑дат

Упрощено формирование row_data без лишних переменных.
Удалены избыточные отладочные println!, уменьшив шум в консоли.
Переписаны комментарии к блокам парсинга, убраны неиспользуемые выводы количества заголовков и строк.
Объединено условие проверки Excel‑даты в компактный диапазон (1.0..100000.0).
Удалены устаревшие комментарии о базовой дате; оставлено только вычисление даты.
Добавлена асинхронная функция cleanup_finished_jobs для очистки завершённых/ошибочных задач Nexrender, с выводом информации о выполненных чистках.
Реализована функция monitor_jobs, периодически проверяющая статус ожидающих заданий, выводящая прогресс и финальное сообщение о завершении всех работ.
Эти изменения делают код чище, повышают читаемость и добавляют автоматизированное управление задачами Nexrender.
This commit is contained in:
2026-04-15 20:25:05 +03:00
parent ce00127978
commit 11fc697771
5 changed files with 1030 additions and 599 deletions

View File

@@ -1,14 +1,24 @@
mod nexrender;
mod synology;
use anyhow::{anyhow, Context, Result};
use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate};
use dotenv::dotenv;
use nexrender::{JobData, LogoRegistry};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use std::env;
use std::fs::File;
use std::io::Write;
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)]
@@ -19,28 +29,6 @@ pub struct 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 {
let rows_json: Vec<Value> = self
.rows
@@ -64,56 +52,38 @@ impl SheetData {
}
}
/// Структура для хранения всех листов Excel
#[derive(Debug, Default)]
pub struct ExcelWorkbook {
pub sheets: Vec<SheetData>,
}
impl ExcelWorkbook {
/// Получить лист по имени
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
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 {
let sheets_json: Vec<Value> = self.sheets.iter().map(|s| s.to_json()).collect();
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]
async fn main() -> Result<(), SynologyError> {
async fn main() -> Result<()> {
dotenv().ok();
let nas_fqdn = env::var("NAS_FQDN").expect("❌ Переменная NAS_FQDN не задана в .env");
let nas_user = env::var("NAS_USER").expect("❌ Переменная NAS_USER не задана в .env");
let nas_pass = env::var("NAS_PASS").expect("❌ Переменная NAS_PASS не задана в .env");
let nas_file = env::var("NAS_FILE").expect("❌ Переменная NAS_FILE не задана в .env");
let nas_fqdn = env::var("NAS_FQDN").context("NAS_FQDN not set")?;
let nas_user = env::var("NAS_USER").context("NAS_USER not set")?;
let nas_pass = env::var("NAS_PASS").context("NAS_PASS not set")?;
let nas_file = env::var("NAS_FILE").context("NAS_FILE not set")?;
let mut client = SynologyClient::new(&nas_fqdn);
client.login(&nas_user, &nas_pass).await?;
println!("✅ Logged in");
let info = client.get_info().await?;
println!("📡 Connected to: {}", info.hostname);
// Получаем имя файла
let file_name_full = Path::new(&nas_file)
.file_name()
.and_then(|n| n.to_str())
@@ -129,20 +99,15 @@ async fn main() -> Result<(), SynologyError> {
.replace(' ', "");
let full_expected_path_api = format!("{}/{}", api_format_path, file_name_full);
println!("\n🔍 Looking for file: {}", full_expected_path_api);
// Ищем файл
let search_result = client.search_file_by_name(&search_name).await?;
let items = search_result["data"]["items"].as_array().ok_or_else(|| {
SynologyError::InvalidResponseFormat("No items in search result".to_string())
})?;
let items = search_result["data"]["items"]
.as_array()
.ok_or_else(|| anyhow!("No items in search result"))?;
if items.is_empty() {
return Err(SynologyError::Api {
code: 404,
message: Some(format!("File '{}' not found", file_name_full)),
});
return Err(anyhow!("File '{}' not found", file_name_full));
}
let exact_match: Vec<&Value> = items
@@ -156,23 +121,19 @@ async fn main() -> Result<(), SynologyError> {
.collect();
if exact_match.is_empty() {
return Err(SynologyError::Api {
code: 400,
message: Some(format!("File not found at: {}", full_expected_path_api)),
});
return Err(anyhow!("File not found at: {}", full_expected_path_api));
}
let file = exact_match[0];
let file_id = file["file_id"]
.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"]
.as_str()
.ok_or_else(|| SynologyError::InvalidResponseFormat("Missing name".to_string()))?;
.ok_or_else(|| anyhow!("Missing name"))?;
println!(" 📄 Found: {}", actual_file_name);
// Экспортируем файл
println!("\n📥 Exporting file...");
let output_path = format!("./{}_export.xlsx", search_name);
@@ -181,11 +142,9 @@ async fn main() -> Result<(), SynologyError> {
file.write_all(&data)?;
println!(" ✅ Exported to: {} ({} bytes)", output_path, data.len());
// Читаем Excel и получаем структурированные данные
println!("\n📖 Parsing Excel file...");
let workbook = read_excel_workbook(&output_path)?;
// Выводим информацию о структуре
println!("\n📊 Excel Structure:");
println!(" Total sheets: {}", workbook.sheets.len());
@@ -197,50 +156,131 @@ async fn main() -> Result<(), SynologyError> {
sheet.headers
);
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_data = workbook.to_json();
let json_str = serde_json::to_string_pretty(&json_data)
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
std::fs::write(&json_output, json_str)?;
std::fs::write(&json_output, serde_json::to_string_pretty(&json_data)?)?;
println!("\n💾 Full workbook saved to: {}", json_output);
// Сохраняем каждый лист отдельно
for sheet in &workbook.sheets {
let safe_name = sheet
.name
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
let sheet_json_path = format!("./{}_{}.json", search_name, safe_name);
let sheet_json = sheet.to_json();
let sheet_json_str = serde_json::to_string_pretty(&sheet_json)
.map_err(|e| SynologyError::InvalidResponseFormat(e.to_string()))?;
std::fs::write(&sheet_json_path, sheet_json_str)?;
println!("💾 Sheet '{}' saved to: {}", sheet.name, sheet_json_path);
std::fs::write(
&sheet_json_path,
serde_json::to_string_pretty(&sheet.to_json())?,
)?;
}
// ============ 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?;
println!("\n👋 Logged out");
println!("\n👋 Done");
Ok(())
}
/// Читает Excel файл и возвращает структурированную книгу
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError> {
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook> {
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 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 {
println!(" 📄 Processing sheet: '{}'", sheet_name);
let range = match workbook.worksheet_range(&sheet_name) {
Ok(range) => range,
Err(e) => {
println!(" ⚠️ Error reading sheet: {}", e);
continue;
}
};
let range = workbook
.worksheet_range(&sheet_name)
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
let sheet_data = parse_sheet_dynamic(&sheet_name, &range)?;
excel_workbook.sheets.push(sheet_data);
@@ -263,12 +299,7 @@ fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook, SynologyError>
Ok(excel_workbook)
}
/// Парсит лист в динамическую структуру
fn parse_sheet_dynamic(
sheet_name: &str,
range: &calamine::Range<Data>,
) -> Result<SheetData, SynologyError> {
// Собираем все ячейки в структуру
fn parse_sheet_dynamic(sheet_name: &str, range: &calamine::Range<Data>) -> Result<SheetData> {
let mut cell_map: HashMap<(usize, usize), String> = HashMap::new();
let mut max_row = 0usize;
let mut max_col = 0usize;
@@ -290,19 +321,15 @@ fn parse_sheet_dynamic(
});
}
// Создаем матрицу данных
let mut data_matrix: Vec<Vec<String>> = Vec::new();
for row in 0..=max_row {
let mut row_data = Vec::new();
for col in 0..=max_col {
let value = cell_map.get(&(row, col)).cloned().unwrap_or_default();
row_data.push(value);
row_data.push(cell_map.get(&(row, col)).cloned().unwrap_or_default());
}
data_matrix.push(row_data);
}
// Первая строка - заголовки
let headers: Vec<String> = if !data_matrix.is_empty() {
data_matrix[0]
.iter()
@@ -320,32 +347,22 @@ fn parse_sheet_dynamic(
Vec::new()
};
println!(" Found {} headers: {:?}", headers.len(), headers);
let mut rows_data = Vec::new();
// Остальные строки - данные
for row_values in data_matrix.iter().skip(1) {
let mut row_map = HashMap::new();
for (col_idx, value) in row_values.iter().enumerate() {
if col_idx < headers.len() && !value.is_empty() {
row_map.insert(headers[col_idx].clone(), value.clone());
}
}
if !row_map.is_empty() {
rows_data.push(row_map);
}
if rows_data.len() >= 10000 {
println!(" ... truncated at 10000 rows");
break;
}
}
println!(" Loaded {} data rows", rows_data.len());
Ok(SheetData {
name: sheet_name.to_string(),
headers,
@@ -353,12 +370,9 @@ fn parse_sheet_dynamic(
})
}
/// Преобразует Excel серийный номер даты в строку формата DD.MM.YYYY
fn excel_date_to_string(serial: f64) -> String {
let days = serial as i64;
// Excel использует 30.12.1899 как базовую дату
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
date.format("%d.%m.%Y").to_string()
} else {
@@ -366,13 +380,10 @@ fn excel_date_to_string(serial: f64) -> String {
}
}
/// Проверяет, является ли число датой Excel
fn is_excel_date(value: f64) -> bool {
// Excel даты обычно в диапазоне от 1 до 100000
value > 1.0 && value < 100000.0
(1.0..100000.0).contains(&value)
}
/// Преобразует ячейку в строку с конвертацией дат
fn cell_to_string(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
@@ -408,3 +419,65 @@ fn cell_to_string(cell: &Data) -> String {
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
View 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
)
}

View File

@@ -1,9 +1,9 @@
use reqwest::{
Client, ClientBuilder,
multipart::{Form, Part},
Client, ClientBuilder,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
@@ -37,7 +37,7 @@ pub enum SynologyError {
Io(#[from] std::io::Error),
#[error("CSV error: {0}")]
Csv(#[from] csv::Error),
Csv(String),
}
/// Результат операций