3 Commits

Author SHA1 Message Date
9b3d959ce3 feat: сортировка заданий по дате и времени, последовательная отправка
- Добавлена сортировка заданий по дате и времени события (самые ранние — первыми)
- Добавлена сортировка по варианту (оригинал -> today -> tomorrow)
- Изменена отправка на последовательную для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2+ дефиса -> 1)
- Исправлен SINGLE шаблон (убран слой TEAMS)
- Добавлена поддержка cache: true/false для ассетов
- Логотипы команд: cache: false (часто меняются)
- Логотип канала: cache: true (редко меняется)
- Видео-пак: cache: true (редко меняется)
- Версия 0.2.6
2026-04-17 20:00:06 +03:00
e06b55ad07 Оптимизирована работа с памятью 2026-04-17 19:14:38 +03:00
c3056010eb v0.2.4 2026-04-17 18:33:40 +03:00
20 changed files with 278 additions and 188 deletions

View File

@@ -12,10 +12,15 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов 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 ### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Тёмная/светлая тема с автоопределением ОС
- Сортировка по всем колонкам в интерфейсе
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование - Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей - Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
### v0.2.2 ### v0.2.2
- Исправлено отображение имён выходных файлов в веб-интерфейсе - Исправлено отображение имён выходных файлов в веб-интерфейсе
- Улучшена цветовая схема - Улучшена цветовая схема
- Добавлена сортировка по всем колонкам - Добавлена сортировка по всем колонкам
- Вынесены стили в отдельный CSS файл - Вынесены стили в отдельный CSS файл
### v0.2.0 ### v0.2.0
- Добавлен веб-интерфейс для управления заданиями - Добавлен веб-интерфейс для управления заданиями
- Реализован REST API - Реализован REST API
- Добавлена поддержка тёмной/светлой темы - Добавлена поддержка тёмной/светлой темы
- Автообновление статуса заданий - Автообновление статуса заданий
- Возможность остановки всех активных заданий - Возможность остановки всех активных заданий
### v0.1.1 ### v0.1.1
- Оптимизирована работа с памятью - Оптимизирована работа с памятью
- Убрана функция создания .json - Убрана функция создания .json
### v0.1.0 ### v0.1.0
- Первый выпуск - Первый выпуск
- Интеграция с Synology Office - Интеграция с Synology Office
- Базовая генерация заданий Nexrender - Базовая генерация заданий Nexrender
- Парсинг Excel с динамическим обнаружением листов - Парсинг Excel с динамическим обнаружением листов
--- ---

View File

@@ -12,17 +12,10 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3 ## Новое в версии v0.2.4
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Тёмная/светлая тема с автоопределением ОС - Улучшена читаемость логов отправки (добавлен UID)
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
## Обзор ## Обзор
@@ -576,6 +569,7 @@ ae_anons/
## 🙏 Благодарности ## 🙏 Благодарности
### Nexrender ### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)** Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы ([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects. для автоматизации After Effects.
@@ -587,7 +581,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL) - **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com © 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия ## Лицензия
@@ -616,7 +610,12 @@ ae_anons/
## История изменений ## История изменений
### v0.2.3 (текущая) ### v0.2.4 (текущая)
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Улучшенное debug-логирование - Улучшенное debug-логирование

View File

@@ -12,17 +12,10 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3 ## Новое в версии v0.2.4
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Тёмная/светлая тема с автоопределением ОС - Улучшена читаемость логов отправки (добавлен UID)
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
## Обзор ## Обзор
@@ -576,6 +569,7 @@ ae_anons/
## 🙏 Благодарности ## 🙏 Благодарности
### Nexrender ### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)** Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы ([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects. для автоматизации After Effects.
@@ -587,7 +581,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL) - **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com © 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия ## Лицензия
@@ -616,7 +610,12 @@ ae_anons/
## История изменений ## История изменений
### v0.2.3 (текущая) ### v0.2.4 (текущая)
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Улучшенное debug-логирование - Улучшенное debug-логирование

View File

@@ -12,17 +12,10 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3 ## Новое в версии v0.2.4
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Тёмная/светлая тема с автоопределением ОС - Улучшена читаемость логов отправки (добавлен UID)
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
## Обзор ## Обзор
@@ -576,6 +569,7 @@ ae_anons/
## 🙏 Благодарности ## 🙏 Благодарности
### Nexrender ### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)** Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы ([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects. для автоматизации After Effects.
@@ -587,7 +581,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL) - **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com © 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия ## Лицензия
@@ -616,7 +610,12 @@ ae_anons/
## История изменений ## История изменений
### v0.2.3 (текущая) ### v0.2.4 (текущая)
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Улучшенное debug-логирование - Улучшенное debug-логирование

View File

@@ -12,17 +12,10 @@
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии 0.2.3 ## Новое в версии v0.2.4
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Тёмная/светлая тема с автоопределением ОС - Улучшена читаемость логов отправки (добавлен UID)
- Сортировка по всем колонкам
- Фильтрация по имени файла/UID
- Остановка всех активных заданий
- Автообновление каждые 60 секунд
- Улучшенное debug-логирование
- Исправлена обработка UNC/SMB путей
- Нормализация множественных пробелов в именах файлов"
## Обзор ## Обзор
@@ -576,6 +569,7 @@ ae_anons/
## 🙏 Благодарности ## 🙏 Благодарности
### Nexrender ### Nexrender
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)** Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы ([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects. для автоматизации After Effects.
@@ -587,7 +581,7 @@ ae_anons/
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL) - **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
© 2023 Fonticons, Inc. — https://fontawesome.com © 2023 Fonticons, Inc. — <https://fontawesome.com>
## Лицензия ## Лицензия
@@ -616,7 +610,12 @@ ae_anons/
## История изменений ## История изменений
### v0.2.3 (текущая) ### v0.2.4 (текущая)
- Групповая отправка заданий по 3 (оригинал + today + tomorrow) для сохранения порядка в очереди
- Улучшена читаемость логов отправки (добавлен UID)
### v0.2.3
- Веб-интерфейс с настраиваемым портом (WEB_PORT) - Веб-интерфейс с настраиваемым портом (WEB_PORT)
- Улучшенное debug-логирование - Улучшенное debug-логирование

View File

@@ -155,6 +155,8 @@ pub enum Asset {
asset_type: String, asset_type: String,
#[serde(rename = "layerName")] #[serde(rename = "layerName")]
layer_name: String, layer_name: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
cache: bool,
}, },
Video { Video {
src: String, src: String,
@@ -162,6 +164,8 @@ pub enum Asset {
asset_type: String, asset_type: String,
#[serde(rename = "layerName")] #[serde(rename = "layerName")]
layer_name: String, 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 channel_logo: String,
pub outfile_name: String, pub outfile_name: String,
pub row_index: usize, pub row_index: usize,
// Поля для сортировки
pub sort_date: NaiveDate,
pub sort_time: String,
pub variant_order: u8,
} }
impl JobData { impl JobData {
@@ -233,6 +241,7 @@ impl JobData {
let (date_obj, data_display) = parse_date(&data_str); let (date_obj, data_display) = parse_date(&data_str);
let (time_h, time_m) = parse_time(&time); 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 pack_path = packs.get(&sport).cloned().unwrap_or_default();
let channel_logo = channels.get(&channel).cloned().unwrap_or_default(); let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
@@ -268,8 +277,8 @@ impl JobData {
.replace(' ', "-") .replace(' ', "-")
.replace('\'', ""); .replace('\'', "");
// Если 3 и более дефисов подряд -> заменяем на один // Заменяем 2 и более дефисов подряд на один
let re = Regex::new(r"-{3,}").unwrap(); let re = Regex::new(r"-{2,}").unwrap();
outfile_name = re.replace_all(&outfile_name, "-").to_string(); outfile_name = re.replace_all(&outfile_name, "-").to_string();
// Убираем дефисы в начале и конце // Убираем дефисы в начале и конце
@@ -294,19 +303,34 @@ impl JobData {
channel_logo: unc_to_uri(&channel_logo), channel_logo: unc_to_uri(&channel_logo),
outfile_name, outfile_name,
row_index, row_index,
sort_date: date_obj,
sort_time,
variant_order: 0,
}) })
} }
pub fn create_variants(&self) -> Vec<Self> { pub fn create_variants(&self) -> Vec<Self> {
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(); let mut today = self.clone();
today.data_display = "сегодня".to_string(); today.data_display = "сегодня".to_string();
today.outfile_name = format!("{}_Today", self.outfile_name); today.outfile_name = format!("{}_Today", self.outfile_name);
today.variant_order = 1;
variants.push(today); variants.push(today);
// Tomorrow (2)
let mut tomorrow = self.clone(); let mut tomorrow = self.clone();
tomorrow.data_display = "завтра".to_string(); tomorrow.data_display = "завтра".to_string();
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name); tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
tomorrow.variant_order = 2;
variants.push(tomorrow); variants.push(tomorrow);
variants variants
} }
@@ -346,6 +370,7 @@ impl JobData {
src: self.pack_path.clone(), src: self.pack_path.clone(),
asset_type: "video".to_string(), asset_type: "video".to_string(),
layer_name: "TOP".to_string(), layer_name: "TOP".to_string(),
cache: true,
}); });
} }
@@ -357,6 +382,7 @@ impl JobData {
} }
fn add_text_assets(&self, assets: &mut Vec<Asset>) { fn add_text_assets(&self, assets: &mut Vec<Asset>) {
// DATA — всегда
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "DATA".to_string(), layer_name: "DATA".to_string(),
@@ -365,7 +391,9 @@ impl JobData {
}); });
self.add_date_adjustments(assets); self.add_date_adjustments(assets);
// TIME — зависит от шаблона
if !self.team_b.is_empty() { if !self.team_b.is_empty() {
// DOUBLE: TIME_H и TIME_M
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TIME_H".to_string(), layer_name: "TIME_H".to_string(),
@@ -380,6 +408,7 @@ impl JobData {
}); });
self.add_time_adjustments(assets); self.add_time_adjustments(assets);
} else { } else {
// SINGLE: TIME
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TIME".to_string(), layer_name: "TIME".to_string(),
@@ -388,6 +417,7 @@ impl JobData {
}); });
} }
// LEAGUE — всегда
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "LEAGUE".to_string(), layer_name: "LEAGUE".to_string(),
@@ -396,6 +426,7 @@ impl JobData {
}); });
self.add_league_adjustments(assets); self.add_league_adjustments(assets);
// SPORT — всегда
if !self.sport.is_empty() { if !self.sport.is_empty() {
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), 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() { // TEAMS — ТОЛЬКО для DOUBLE шаблона
format!("{} - {}", self.team_a, self.team_b) if !self.team_b.is_empty() {
} else if !self.team_a.is_empty() { let teams_text = format!("{} - {}", self.team_a, self.team_b);
self.team_a.clone()
} else {
self.team_b.clone()
};
if !teams_text.is_empty() {
assets.push(Asset::Data { assets.push(Asset::Data {
asset_type: "data".to_string(), asset_type: "data".to_string(),
layer_name: "TEAMS".to_string(), layer_name: "TEAMS".to_string(),
@@ -485,11 +511,13 @@ impl JobData {
} }
fn add_image_assets(&self, assets: &mut Vec<Asset>) { fn add_image_assets(&self, assets: &mut Vec<Asset>) {
// Логотипы команд — НЕ кешируем (часто меняются)
if !self.team_a_logo.is_empty() { if !self.team_a_logo.is_empty() {
assets.push(Asset::Image { assets.push(Asset::Image {
src: self.team_a_logo.clone(), src: self.team_a_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "TEAM_A_LOGO".to_string(), layer_name: "TEAM_A_LOGO".to_string(),
cache: false,
}); });
if let Some(res) = &self.team_a_logo_res { if let Some(res) = &self.team_a_logo_res {
assets.push(Asset::DataExpression { assets.push(Asset::DataExpression {
@@ -505,6 +533,7 @@ impl JobData {
src: self.team_b_logo.clone(), src: self.team_b_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "TEAM_B_LOGO".to_string(), layer_name: "TEAM_B_LOGO".to_string(),
cache: false,
}); });
if let Some(res) = &self.team_b_logo_res { if let Some(res) = &self.team_b_logo_res {
assets.push(Asset::DataExpression { assets.push(Asset::DataExpression {
@@ -515,11 +544,13 @@ impl JobData {
}); });
} }
} }
// Логотип канала — КЕШИРУЕМ (редко меняется)
if !self.channel_logo.is_empty() { if !self.channel_logo.is_empty() {
assets.push(Asset::Image { assets.push(Asset::Image {
src: self.channel_logo.clone(), src: self.channel_logo.clone(),
asset_type: "image".to_string(), asset_type: "image".to_string(),
layer_name: "CHANELL".to_string(), layer_name: "CHANELL".to_string(),
cache: true,
}); });
} }
} }
@@ -545,7 +576,6 @@ impl LogoRegistry {
self.logos.insert((team, sport), link); self.logos.insert((team, sport), link);
} }
// В impl LogoRegistry, функция find()
pub fn find(&self, team: &str, sport: &str) -> Option<String> { pub fn find(&self, team: &str, sport: &str) -> Option<String> {
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport); log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
@@ -564,6 +594,19 @@ impl LogoRegistry {
log::warn!("No logo found for team='{}', sport='{}'", team, sport); log::warn!("No logo found for team='{}', sport='{}'", team, sport);
None None
} }
/// Оставляет только указанные команды, удаляя все остальные
pub fn retain<F>(&mut self, mut predicate: F)
where
F: FnMut(&str, &str) -> bool,
{
self.logos.retain(|(team, sport), _| predicate(team, sport));
}
/// Возвращает количество записей в реестре
pub fn len(&self) -> usize {
self.logos.len()
}
} }
fn parse_date(s: &str) -> (NaiveDate, String) { fn parse_date(s: &str) -> (NaiveDate, String) {
@@ -639,6 +682,7 @@ fn unc_to_uri(path: &str) -> String {
format!("file://{}", trimmed) format!("file://{}", trimmed)
} }
fn logo_scale_expression(target_size: &str) -> String { fn logo_scale_expression(target_size: &str) -> String {
format!( format!(
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]", "if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",

View File

@@ -274,52 +274,50 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
} }
} }
// Вспомогательная функция для поиска значения по части ключа
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
if let Some(val) = row.get(key_part) {
if !val.is_empty() {
return Some(val.clone());
}
}
for (k, v) in row.iter() {
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
return Some(v.clone());
}
}
None
}
async fn generate_and_submit_jobs( async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook, workbook: &ExcelWorkbook,
config: &Config, config: &Config,
) -> Result<Vec<(String, String)>> { ) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs..."); info!("Preparing Nexrender jobs...");
let start_sheet = workbook // ========== ЗАГРУЖАЕМ SPORT PACKS ==========
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
let sport_sheet = workbook let sport_sheet = workbook
.get_sheet("SPORT") .get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?; .ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
let teams_sheet = workbook
.get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
// Загружаем sport packs
let packs: HashMap<String, String> = sport_sheet let packs: HashMap<String, String> = sport_sheet
.rows .rows
.iter() .iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone()))) .filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect(); .collect();
info!("Loaded {} sport packs", packs.len()); info!("Loaded {} sport packs", packs.len());
debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len());
// Вспомогательная функция для поиска значения по части ключа // ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> { let teams_sheet = workbook
if let Some(val) = row.get(key_part) { .get_sheet("TEAMS")
if !val.is_empty() { .ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
return Some(val.clone());
}
}
for (k, v) in row.iter() {
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
return Some(v.clone());
}
}
None
}
// Загружаем team logos
debug!("TEAMS headers: {:?}", teams_sheet.headers); debug!("TEAMS headers: {:?}", teams_sheet.headers);
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() { if log_enabled!(Level::Debug) {
debug!("TEAMS row {}: {:?}", i, row); 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()); let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
@@ -332,7 +330,8 @@ async fn generate_and_submit_jobs(
logos.insert(team.clone(), sport.clone(), link.clone()); logos.insert(team.clone(), sport.clone(), link.clone());
} }
} }
info!("Loaded {} team logos", teams_sheet.rows.len()); let total_teams = teams_sheet.rows.len();
info!("Loaded {} team logos", total_teams);
// Дебаг: выводим статистику по TEAMS // Дебаг: выводим статистику по TEAMS
if log_enabled!(Level::Debug) { if log_enabled!(Level::Debug) {
@@ -343,19 +342,23 @@ async fn generate_and_submit_jobs(
.collect(); .collect();
debug!("Sports found in TEAMS sheet: {:?}", sports); debug!("Sports found in TEAMS sheet: {:?}", sports);
for sport in sports.iter() { for sport in sports.iter().take(5) {
let examples: Vec<_> = teams_sheet let examples: Vec<_> = teams_sheet
.rows .rows
.iter() .iter()
.filter(|r| r.get("SPORT") == Some(*sport)) .filter(|r| r.get("SPORT") == Some(*sport))
.take(5) .take(3)
.filter_map(|r| r.get("TEAM")) .filter_map(|r| r.get("TEAM"))
.collect(); .collect();
debug!(" TEAMS examples for '{}': {:?}", sport, examples); debug!(" TEAMS examples for '{}': {:?}", sport, examples);
} }
} }
// Загружаем channel logos // ========== ЗАГРУЖАЕМ CHANNEL LOGOS ==========
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
let channels: HashMap<String, String> = channel_sheet let channels: HashMap<String, String> = channel_sheet
.rows .rows
.iter() .iter()
@@ -367,40 +370,76 @@ async fn generate_and_submit_jobs(
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>()); debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
} }
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3); // ========== ОБРАБАТЫВАЕМ 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<String, String>)> = start_sheet
.rows
.iter()
.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(),
total_start_rows
);
// Собираем использованные команды для очистки logos
let mut used_teams: HashSet<(String, String)> = HashSet::new();
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
// Для дебага
let mut missing_teams: HashSet<String> = HashSet::new(); let mut missing_teams: HashSet<String> = HashSet::new();
let mut missing_sports: HashSet<String> = HashSet::new(); let mut missing_sports: HashSet<String> = HashSet::new();
for (idx, row) in start_sheet.rows.iter().enumerate() { for (idx, row) in active_rows {
if let Some(state) = row.get("STATE") { let team_a = row.get("TEAM A").cloned().unwrap_or_default();
if state == "FALSE" { let team_b = row.get("TEAM B").cloned().unwrap_or_default();
let team_a = row.get("TEAM A").cloned().unwrap_or_default(); let sport = row.get("SPORT").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 log_enabled!(Level::Debug) { // Запоминаем использованные команды
let logo_a = logos.find(&team_a, &sport); if !team_a.is_empty() {
let logo_b = logos.find(&team_b, &sport); used_teams.insert((team_a.clone(), sport.clone()));
}
if !team_b.is_empty() {
used_teams.insert((team_b.clone(), sport.clone()));
}
if !team_a.is_empty() && logo_a.is_none() { if log_enabled!(Level::Debug) {
missing_teams.insert(team_a.clone()); let logo_a = logos.find(&team_a, &sport);
missing_sports.insert(sport.clone()); let logo_b = logos.find(&team_b, &sport);
}
if !team_b.is_empty() && logo_b.is_none() {
missing_teams.insert(team_b.clone());
missing_sports.insert(sport.clone());
}
}
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { if !team_a.is_empty() && logo_a.is_none() {
jobs.extend(job.create_variants()); missing_teams.insert(team_a.clone());
} missing_sports.insert(sport.clone());
}
if !team_b.is_empty() && logo_b.is_none() {
missing_teams.insert(team_b.clone());
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)",
logos.len(),
logos_before - logos.len()
);
// Дебаг: выводим сводку по отсутствующим логотипам // Дебаг: выводим сводку по отсутствующим логотипам
if log_enabled!(Level::Debug) && !missing_teams.is_empty() { if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
debug!("=== MISSING LOGOS SUMMARY ==="); debug!("=== MISSING LOGOS SUMMARY ===");
@@ -415,72 +454,68 @@ async fn generate_and_submit_jobs(
info!("Generated {} total jobs (including variants)", jobs.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() { if jobs.is_empty() {
info!("No jobs with STATE='FALSE' found"); info!("No jobs with STATE='FALSE' found");
return Ok(Vec::new()); 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 http_client = Client::new();
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len()); let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
// Отправляем задания группами по 3 (оригинал + today + tomorrow) for job in jobs {
for chunk in jobs.chunks(3) { let nexrender_job = job.to_nexrender_job(config);
let mut tasks = Vec::with_capacity(3); info!("Submitting job: {}", job.outfile_name);
for job in chunk { if log_enabled!(Level::Debug) {
let client = http_client.clone(); debug!(
let api_url = config.nexrender_api_url.clone(); "Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'",
let config_clone = config.clone(); job.sport, job.league, job.channel, job.team_a, job.team_b
let job_owned = job.clone(); );
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
);
}
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)
}
}));
} }
// Ждём завершения группы let response = http_client
for task in tasks { .post(&config.nexrender_api_url)
match task.await { .json(&nexrender_job)
Ok(Ok(Some((uid, outfile_name)))) => { .send()
info!("Job submitted: {} (UID: {})", outfile_name, uid); .await?;
submitted_jobs.push((uid, outfile_name));
} if response.status().is_success() {
Ok(Ok(None)) => { let result: Value = response.json().await?;
debug!("Job submission returned no UID"); if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
} info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
Ok(Err(e)) => { submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
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);
} }
} }