diff --git a/README.md b/README.md index a5df4b7..0e8f255 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,15 @@ Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. -## Новое в версии v0.2.4 +## Новое в версии v0.2.6 -- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди -- Улучшена читаемость логов отправки (добавлен UID) +- Сортировка заданий по дате и времени события (самые ранние — первыми) +- Сортировка по варианту (оригинал → today → tomorrow) +- Последовательная отправка для гарантированного порядка в очереди +- Исправлена нормализация дефисов (2 и более дефиса подряд → 1) +- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS) +- Добавлена поддержка кеширования ассетов (cache: true/false) +- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются ## Обзор @@ -610,54 +615,64 @@ ae_anons/ ## История изменений -### v0.2.4 (текущая) +### v0.2.6 (текущая) -- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди -- Улучшена читаемость логов отправки (добавлен UID) +- Сортировка заданий по дате и времени события (самые ранние — первыми) +- Сортировка по варианту (оригинал → today → tomorrow) +- Последовательная отправка для гарантированного порядка в очереди +- Исправлена нормализация дефисов (2 и более дефиса подряд → 1) +- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS) +- Добавлена поддержка кеширования ассетов (cache: true/false) +- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются + +### v0.2.5 + +- Оптимизация памяти: очистка неиспользуемых логотипов +- Групповая отправка заданий по 3 для сохранения порядка +- Добавлен UID в логи отправки + +### v0.2.4 + +- Полностью автономный режим (встроенный Font Awesome) +- Добавлен favicon +- Все статические ресурсы встроены в бинарник ### v0.2.3 - Веб-интерфейс с настраиваемым портом (WEB_PORT) +- Тёмная/светлая тема с автоопределением ОС +- Сортировка по всем колонкам в интерфейсе +- Фильтрация по имени файла/UID +- Остановка всех активных заданий +- Автообновление каждые 60 секунд - Улучшенное debug-логирование - Исправлена обработка UNC/SMB путей -- Нормализация множественных пробелов в именах файлов" ### v0.2.2 - Исправлено отображение имён выходных файлов в веб-интерфейсе - - Улучшена цветовая схема - - Добавлена сортировка по всем колонкам - - Вынесены стили в отдельный CSS файл ### v0.2.0 - Добавлен веб-интерфейс для управления заданиями - - Реализован REST API - - Добавлена поддержка тёмной/светлой темы - - Автообновление статуса заданий - - Возможность остановки всех активных заданий ### v0.1.1 - Оптимизирована работа с памятью - - Убрана функция создания .json ### v0.1.0 - Первый выпуск - - Интеграция с Synology Office - - Базовая генерация заданий Nexrender - - Парсинг Excel с динамическим обнаружением листов --- diff --git a/src/nexrender.rs b/src/nexrender.rs index 3a48d37..264c34b 100644 --- a/src/nexrender.rs +++ b/src/nexrender.rs @@ -155,6 +155,8 @@ pub enum Asset { asset_type: String, #[serde(rename = "layerName")] layer_name: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + cache: bool, }, Video { src: String, @@ -162,6 +164,8 @@ pub enum Asset { asset_type: String, #[serde(rename = "layerName")] layer_name: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + cache: bool, }, } @@ -208,6 +212,10 @@ pub struct JobData { pub channel_logo: String, pub outfile_name: String, pub row_index: usize, + // Поля для сортировки + pub sort_date: NaiveDate, + pub sort_time: String, + pub variant_order: u8, } impl JobData { @@ -233,6 +241,7 @@ impl JobData { let (date_obj, data_display) = parse_date(&data_str); let (time_h, time_m) = parse_time(&time); + let sort_time = format!("{}:{}", time_h, time_m); let pack_path = packs.get(&sport).cloned().unwrap_or_default(); let channel_logo = channels.get(&channel).cloned().unwrap_or_default(); @@ -268,13 +277,13 @@ impl JobData { .replace(' ', "-") .replace('\'', ""); - // Если 3 и более дефисов подряд -> заменяем на один - let re = Regex::new(r"-{3,}").unwrap(); + // Заменяем 2 и более дефисов подряд на один + let re = Regex::new(r"-{2,}").unwrap(); outfile_name = re.replace_all(&outfile_name, "-").to_string(); // Убираем дефисы в начале и конце outfile_name = outfile_name.trim_matches('-').to_string(); - + Some(Self { date: data_str, time: time.clone(), @@ -294,19 +303,34 @@ impl JobData { channel_logo: unc_to_uri(&channel_logo), outfile_name, row_index, + sort_date: date_obj, + sort_time, + variant_order: 0, }) } pub fn create_variants(&self) -> Vec { - let mut variants = vec![self.clone()]; + let mut variants = Vec::new(); + + // Оригинал (0) + let mut original = self.clone(); + original.variant_order = 0; + variants.push(original); + + // Today (1) let mut today = self.clone(); today.data_display = "сегодня".to_string(); today.outfile_name = format!("{}_Today", self.outfile_name); + today.variant_order = 1; variants.push(today); + + // Tomorrow (2) let mut tomorrow = self.clone(); tomorrow.data_display = "завтра".to_string(); tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name); + tomorrow.variant_order = 2; variants.push(tomorrow); + variants } @@ -346,6 +370,7 @@ impl JobData { src: self.pack_path.clone(), asset_type: "video".to_string(), layer_name: "TOP".to_string(), + cache: true, }); } @@ -357,6 +382,7 @@ impl JobData { } fn add_text_assets(&self, assets: &mut Vec) { + // DATA — всегда assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "DATA".to_string(), @@ -365,7 +391,9 @@ impl JobData { }); self.add_date_adjustments(assets); + // TIME — зависит от шаблона if !self.team_b.is_empty() { + // DOUBLE: TIME_H и TIME_M assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "TIME_H".to_string(), @@ -380,6 +408,7 @@ impl JobData { }); self.add_time_adjustments(assets); } else { + // SINGLE: TIME assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "TIME".to_string(), @@ -388,6 +417,7 @@ impl JobData { }); } + // LEAGUE — всегда assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "LEAGUE".to_string(), @@ -396,6 +426,7 @@ impl JobData { }); self.add_league_adjustments(assets); + // SPORT — всегда if !self.sport.is_empty() { assets.push(Asset::Data { asset_type: "data".to_string(), @@ -405,14 +436,9 @@ impl JobData { }); } - let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() { - 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() { + // TEAMS — ТОЛЬКО для DOUBLE шаблона + if !self.team_b.is_empty() { + let teams_text = format!("{} - {}", self.team_a, self.team_b); assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "TEAMS".to_string(), @@ -485,11 +511,13 @@ impl JobData { } fn add_image_assets(&self, assets: &mut Vec) { + // Логотипы команд — НЕ кешируем (часто меняются) 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(), + cache: false, }); if let Some(res) = &self.team_a_logo_res { assets.push(Asset::DataExpression { @@ -505,6 +533,7 @@ impl JobData { src: self.team_b_logo.clone(), asset_type: "image".to_string(), layer_name: "TEAM_B_LOGO".to_string(), + cache: false, }); if let Some(res) = &self.team_b_logo_res { assets.push(Asset::DataExpression { @@ -515,11 +544,13 @@ impl JobData { }); } } + // Логотип канала — КЕШИРУЕМ (редко меняется) 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(), + cache: true, }); } } @@ -545,7 +576,6 @@ impl LogoRegistry { self.logos.insert((team, sport), link); } - // В impl LogoRegistry, функция find() pub fn find(&self, team: &str, sport: &str) -> Option { log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport); @@ -572,12 +602,11 @@ impl LogoRegistry { { self.logos.retain(|(team, sport), _| predicate(team, sport)); } - + /// Возвращает количество записей в реестре pub fn len(&self) -> usize { self.logos.len() } - } fn parse_date(s: &str) -> (NaiveDate, String) { @@ -653,6 +682,7 @@ fn unc_to_uri(path: &str) -> String { format!("file://{}", trimmed) } + 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]", diff --git a/src/processor.rs b/src/processor.rs index 4076c9b..7b6c07b 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -295,35 +295,31 @@ async fn generate_and_submit_jobs( ) -> Result> { info!("Preparing Nexrender jobs..."); -// ========== ЗАГРУЖАЕМ SPORT PACKS ========== -let sport_sheet = workbook - .get_sheet("SPORT") - .ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; + // ========== ЗАГРУЖАЕМ SPORT PACKS ========== + let sport_sheet = workbook + .get_sheet("SPORT") + .ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; -let packs: HashMap = sport_sheet - .rows - .iter() - .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) - .collect(); -info!("Loaded {} sport packs", packs.len()); -// sport_sheet автоматически освободится при выходе из функции - - // Лист SPORT больше не нужен - let sport_rows_count = sport_sheet.rows.len(); - debug!("Dropped SPORT sheet ({} rows)", sport_rows_count); + let packs: HashMap = sport_sheet + .rows + .iter() + .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) + .collect(); + info!("Loaded {} sport packs", packs.len()); + debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len()); // ========== ЗАГРУЖАЕМ TEAM LOGOS ========== let teams_sheet = workbook .get_sheet("TEAMS") .ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?; - + debug!("TEAMS headers: {:?}", teams_sheet.headers); if log_enabled!(Level::Debug) { for (i, row) in teams_sheet.rows.iter().take(3).enumerate() { debug!("TEAMS row {}: {:?}", i, row); } } - + let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len()); for row in &teams_sheet.rows { let team = get_cell_fuzzy(row, "TEAM"); @@ -336,7 +332,7 @@ info!("Loaded {} sport packs", packs.len()); } let total_teams = teams_sheet.rows.len(); info!("Loaded {} team logos", total_teams); - + // Дебаг: выводим статистику по TEAMS if log_enabled!(Level::Debug) { let sports: HashSet<_> = teams_sheet @@ -357,32 +353,30 @@ info!("Loaded {} sport packs", packs.len()); debug!(" TEAMS examples for '{}': {:?}", sport, examples); } } - // ========== ЗАГРУЖАЕМ CHANNEL LOGOS ========== let channel_sheet = workbook .get_sheet("CHANELL") .ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?; - + let channels: HashMap = channel_sheet .rows .iter() .filter_map(|row| Some((row.get("CHANELL")?.clone(), row.get("LINK")?.clone()))) .collect(); info!("Loaded {} channel logos", channels.len()); - + if log_enabled!(Level::Debug) { debug!("Channels loaded: {:?}", channels.keys().collect::>()); } - // ========== ОБРАБАТЫВАЕМ START (только активные строки) ========== let start_sheet = workbook .get_sheet("Start") .ok_or_else(|| anyhow!("Sheet 'Start' not found"))?; - + let total_start_rows = start_sheet.rows.len(); - + // Сразу фильтруем только строки с STATE = "FALSE" let active_rows: Vec<(usize, &HashMap)> = start_sheet .rows @@ -390,26 +384,26 @@ info!("Loaded {} sport packs", packs.len()); .enumerate() .filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE")) .collect(); - + info!( - "Found {} active rows (STATE='FALSE') out of {} total", - active_rows.len(), + "Found {} active rows (STATE='FALSE') out of {} total", + active_rows.len(), total_start_rows ); - + // Собираем использованные команды для очистки logos let mut used_teams: HashSet<(String, String)> = HashSet::new(); let mut jobs: Vec = Vec::with_capacity(active_rows.len() * 3); - + // Для дебага let mut missing_teams: HashSet = HashSet::new(); let mut missing_sports: HashSet = HashSet::new(); - + for (idx, row) in active_rows { let team_a = row.get("TEAM A").cloned().unwrap_or_default(); let team_b = row.get("TEAM B").cloned().unwrap_or_default(); let sport = row.get("SPORT").cloned().unwrap_or_default(); - + // Запоминаем использованные команды if !team_a.is_empty() { used_teams.insert((team_a.clone(), sport.clone())); @@ -417,7 +411,7 @@ info!("Loaded {} sport packs", packs.len()); if !team_b.is_empty() { used_teams.insert((team_b.clone(), sport.clone())); } - + if log_enabled!(Level::Debug) { let logo_a = logos.find(&team_a, &sport); let logo_b = logos.find(&team_b, &sport); @@ -431,21 +425,20 @@ info!("Loaded {} sport packs", packs.len()); missing_sports.insert(sport.clone()); } } - + if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { jobs.extend(job.create_variants()); } } - + // Очищаем logos от неиспользуемых команд let logos_before = logos.len(); logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string()))); info!( - "Retained {} used team logos (cleaned up {} unused)", + "Retained {} used team logos (cleaned up {} unused)", logos.len(), logos_before - logos.len() ); - // Дебаг: выводим сводку по отсутствующим логотипам if log_enabled!(Level::Debug) && !missing_teams.is_empty() { @@ -461,75 +454,71 @@ info!("Loaded {} sport packs", packs.len()); info!("Generated {} total jobs (including variants)", jobs.len()); + // ========== СОРТИРОВКА ПО ДАТЕ И ВРЕМЕНИ ========== + jobs.sort_by(|a, b| match a.sort_date.cmp(&b.sort_date) { + std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) { + std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order), + other => other, + }, + other => other, + }); + + info!("Jobs sorted by date and time (earliest first)"); + + if log_enabled!(Level::Debug) { + for (i, job) in jobs.iter().take(10).enumerate() { + debug!( + " {}: {} {} - {} (variant: {})", + i + 1, + job.sort_date, + job.sort_time, + job.outfile_name, + job.variant_order + ); + } + } + if jobs.is_empty() { info!("No jobs with STATE='FALSE' found"); return Ok(Vec::new()); } -// ========== ОТПРАВКА ЗАДАНИЙ ========== -cleanup_finished_jobs(&config.nexrender_api_url).await?; + // ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ========== + cleanup_finished_jobs(&config.nexrender_api_url).await?; -let http_client = Client::new(); -let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); + let http_client = Client::new(); + let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); -// Запускаем ВСЕ группы параллельно -let mut all_tasks = Vec::new(); + for job in jobs { + let nexrender_job = job.to_nexrender_job(config); + info!("Submitting job: {}", job.outfile_name); -for chunk in jobs.chunks(3) { - for job in chunk { - let client = http_client.clone(); - let api_url = config.nexrender_api_url.clone(); - let config_clone = config.clone(); - let job_owned = job.clone(); - - all_tasks.push(tokio::spawn(async move { - let nexrender_job = job_owned.to_nexrender_job(&config_clone); - info!("Submitting job: {}", job_owned.outfile_name); - - if log_enabled!(Level::Debug) { - debug!( - "Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'", - job_owned.sport, job_owned.league, job_owned.channel, job_owned.team_a, job_owned.team_b - ); + if log_enabled!(Level::Debug) { + debug!( + "Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'", + job.sport, job.league, job.channel, job.team_a, job.team_b + ); + } + + let response = http_client + .post(&config.nexrender_api_url) + .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()) { + info!("Job submitted: {} (UID: {})", job.outfile_name, uid); + submitted_jobs.push((uid.to_string(), job.outfile_name.clone())); } - - let response = client.post(&api_url).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()) { - Ok::<_, anyhow::Error>(Some((uid.to_string(), job_owned.outfile_name))) - } else { - Ok(None) - } - } else { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - error!("Failed to submit job ({}): {}", status, text); - Ok(None) - } - })); - } -} - -// Ждём завершения ВСЕХ задач -for task in all_tasks { - match task.await { - Ok(Ok(Some((uid, outfile_name)))) => { - info!("Job submitted: {} (UID: {})", outfile_name, uid); - submitted_jobs.push((uid, outfile_name)); - } - Ok(Ok(None)) => { - debug!("Job submission returned no UID"); - } - Ok(Err(e)) => { - error!("Job submission error: {}", e); - } - Err(e) => { - error!("Task join error: {}", e); + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + error!("Failed to submit job ({}): {}", status, text); } } -} + info!( "Successfully submitted {} jobs to Nexrender", submitted_jobs.len() @@ -568,4 +557,4 @@ pub async fn fetch_all_jobs(api_url: &str) -> Result> { } else { Ok(Vec::new()) } -} \ No newline at end of file +}