1
1
Files
audio-catalyst/internal/presentation/tui/manager.go

529 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tui
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"audio-catalyst/internal/domain/entities"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"gopkg.in/yaml.v3"
)
// ConfigData структура для хранения данных конфигурации
type ConfigData struct {
Scanner struct {
SourceDirectory string `yaml:"source_directory"`
TargetDirectory string `yaml:"target_directory"`
} `yaml:"scanner"`
RuTracker struct {
BaseURL string `yaml:"base_url"`
UserAgent string `yaml:"user_agent"`
RequestDelay int `yaml:"request_delay"`
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"rutracker"`
Processing struct {
ParallelWorkers int `yaml:"parallel_workers"`
TimeoutSeconds int `yaml:"timeout_seconds"`
RetryAttempts int `yaml:"retry_attempts"`
} `yaml:"processing"`
Output struct {
LogLevel string `yaml:"log_level"`
ProgressBar bool `yaml:"progress_bar"`
LogToFile bool `yaml:"log_to_file"`
LogFileName string `yaml:"log_file_name"`
LogMaxSizeMB int `yaml:"log_max_size_mb"`
} `yaml:"output"`
}
// Manager управляет TUI интерфейсом
type Manager struct {
app *tview.Application
pages *tview.Pages
currentScreen entities.UIScreen
// Основные компоненты
mainFlex *tview.Flex
headerBar *tview.TextView
footerBar *tview.TextView
statusBar *tview.TextView
// Компоненты главного меню
menuList *tview.List
// Компоненты обработки
logView *tview.TextView
progressBar *tview.TextView
// Компоненты конфигурации
configForm *tview.Form
// Счетчики прогресса
totalFolders int
processedFolders int
// Состояние
logBuffer []string
maxLogLines int
mu sync.Mutex
// Каналы для обновлений
statusUpdate chan entities.ProcessingStatus
logUpdate chan string
// Колбэки действий UI
onStartProcessing func()
}
// NewManager создает новый TUI менеджер
func NewManager() *Manager {
return &Manager{
app: tview.NewApplication(),
maxLogLines: 100,
logBuffer: make([]string, 0),
statusUpdate: make(chan entities.ProcessingStatus, 100),
logUpdate: make(chan string, 100),
}
}
// SetOnStartProcessing регистрирует обработчик запуска обработки
func (m *Manager) SetOnStartProcessing(handler func()) {
m.onStartProcessing = handler
}
// Initialize инициализирует UI компоненты
func (m *Manager) Initialize() {
m.initializeComponents()
m.setupMainMenu()
m.setupProcessingScreen()
m.setupConfigScreen()
m.setupSettingsScreen()
m.setupResultsScreen()
m.setupPages()
}
// Run запускает TUI приложение
func (m *Manager) Run() error {
go m.handleUpdates()
return m.app.Run()
}
// Stop останавливает TUI приложение
func (m *Manager) Stop() {
m.app.Stop()
}
// SendStatusUpdate отправляет обновление статуса
func (m *Manager) SendStatusUpdate(status entities.ProcessingStatus) {
select {
case m.statusUpdate <- status:
default:
}
}
// SendLogUpdate отправляет обновление лога
func (m *Manager) SendLogUpdate(message string) {
select {
case m.logUpdate <- message:
default:
}
}
// initializeComponents инициализирует основные компоненты
func (m *Manager) initializeComponents() {
// Создаем основные элементы
m.headerBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[blue::b]🎵 AudioBook Catalyst v1.0 🎵[-:-:-]")
m.headerBar.SetBorder(true)
m.footerBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[grey]ESC: Выход | TAB: Навигация | ENTER: Выбор[-]")
m.statusBar = tview.NewTextView().
SetDynamicColors(true).
SetText("[green]Готов к работе[-]")
// Создаем систему страниц заранее
m.pages = tview.NewPages()
// Создаем главный контейнер: Header | Pages | Footer
m.mainFlex = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.headerBar, 3, 0, false).
AddItem(m.pages, 0, 1, true).
AddItem(m.footerBar, 1, 0, false)
}
// setupMainMenu настраивает главное меню
func (m *Manager) setupMainMenu() {
m.menuList = tview.NewList().
AddItem("🚀 Обработка аудиокниг", "Начать обработку аудиокниг в указанных папках", '1', func() {
m.switchToProcessing()
if m.onStartProcessing != nil {
go m.onStartProcessing()
}
}).
AddItem("⚙️ Конфигурация", "Настройка параметров приложения", '2', func() {
m.switchToConfig()
}).
AddItem("❌ Выход", "Завершить работу приложения", 'q', func() {
m.app.Stop()
})
m.menuList.SetBorder(true).SetTitle(" Выберите задачу ")
// Увеличиваем размер элементов списка
m.menuList.SetSelectedBackgroundColor(tcell.ColorBlue)
m.menuList.SetSelectedTextColor(tcell.ColorWhite)
// Создаем страницу с меню на весь экран
m.pages.AddPage("main", m.menuList, true, true)
}
// setupProcessingScreen настраивает экран обработки
func (m *Manager) setupProcessingScreen() {
m.logView = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWrap(true)
m.logView.SetBorder(true).SetTitle(" Лог обработки ")
m.progressBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter)
m.progressBar.SetBorder(true).SetTitle(" Прогресс ")
// Горизонтальный прогресс ниже логов
progressFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.progressBar, 3, 0, false)
processingFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.logView, 0, 1, true).
AddItem(progressFlex, 4, 0, false)
// Инициализируем прогресс-бар текстом по умолчанию
m.updateProgressDisplay()
m.pages.AddPage("processing", processingFlex, true, false)
}
// setupConfigScreen настраивает экран конфигурации
func (m *Manager) setupConfigScreen() {
m.configForm = tview.NewForm().
AddInputField("Исходная папка:", "", 50, nil, nil).
AddInputField("Целевая папка:", "", 50, nil, nil).
AddInputField("URL RuTracker:", "", 50, nil, nil).
AddInputField("User Agent:", "", 50, nil, nil).
AddInputField("Задержка запросов (мс):", "", 20, nil, nil).
AddInputField("Имя пользователя:", "", 30, nil, nil).
AddPasswordField("Пароль:", "", 30, '*', nil).
AddInputField("Параллельные воркеры:", "", 10, nil, nil).
AddInputField("Таймаут (сек):", "", 10, nil, nil).
AddInputField("Попытки повтора:", "", 10, nil, nil).
AddDropDown("Уровень логирования:", []string{"debug", "info", "warn", "error"}, 0, nil).
AddCheckbox("Индикатор прогресса:", true, nil).
AddCheckbox("Логирование в файл:", true, nil).
AddInputField("Имя лог файла:", "", 30, nil, nil).
AddInputField("Макс размер лога (МБ):", "", 10, nil, nil).
AddButton("Сохранить", func() {
m.saveConfig()
}).
AddButton("Отмена", func() {
m.switchToMain()
})
m.configForm.SetBorder(true).SetTitle(" Конфигурация ")
m.pages.AddPage("config", m.configForm, true, false)
}
// setupSettingsScreen настраивает экран настроек
func (m *Manager) setupSettingsScreen() {
// Убираем экран настроек, так как он не нужен в упрощенном интерфейсе
}
// setupResultsScreen настраивает экран результатов
func (m *Manager) setupResultsScreen() {
// Убираем экран результатов, так как он не нужен в упрощенном интерфейсе
}
// setupPages настраивает систему страниц
func (m *Manager) setupPages() {
m.app.SetRoot(m.mainFlex, true)
// Глобальные горячие клавиши
m.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
m.switchToMain()
return nil
case tcell.KeyF1:
m.switchToMain()
return nil
case tcell.KeyF2:
m.switchToProcessing()
return nil
}
return event
})
}
// handleUpdates обрабатывает обновления в горутине
func (m *Manager) handleUpdates() {
for {
select {
case status := <-m.statusUpdate:
m.app.QueueUpdateDraw(func() {
m.updateProgress(status)
})
case logMsg := <-m.logUpdate:
m.app.QueueUpdateDraw(func() {
m.addLogMessage(logMsg)
})
}
}
}
// SetTotalFolders устанавливает общее количество папок для обработки
func (m *Manager) SetTotalFolders(total int) {
m.totalFolders = total
m.processedFolders = 0
m.updateProgressDisplay()
}
// IncrementProgress увеличивает счетчик обработанных папок
func (m *Manager) IncrementProgress() {
m.processedFolders++
m.updateProgressDisplay()
}
// updateProgressDisplay обновляет отображение прогресса
func (m *Manager) updateProgressDisplay() {
if m.totalFolders > 0 {
progress := int(math.Round(float64(m.processedFolders) * 100.0 / float64(m.totalFolders)))
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
bar := m.renderProgressBar()
m.progressBar.SetText(fmt.Sprintf("[cyan::b]Прогресс:[-] %s [green]%d%%[-] [gray](%d/%d книг)[-]", bar, progress, m.processedFolders, m.totalFolders))
} else {
m.progressBar.SetText("[yellow]Подсчет книг...[-]")
}
}
// renderProgressBar рисует горизонтальный прогресс-бар фиксированной ширины (■ — обработано, □ — ожидает)
func (m *Manager) renderProgressBar() string {
const width = 30
if m.totalFolders <= 0 {
return strings.Repeat("[white]□[-]", width)
}
filled := int(math.Round(float64(m.processedFolders) / float64(m.totalFolders) * float64(width)))
if filled < 0 {
filled = 0
}
if filled > width {
filled = width
}
var b strings.Builder
for i := 0; i < filled; i++ {
b.WriteString("[green]■[-]")
}
for i := filled; i < width; i++ {
b.WriteString("[white]□[-]")
}
return b.String()
}
// updateProgress обновляет прогресс (без вывода служебных статусов под баром)
func (m *Manager) updateProgress(status entities.ProcessingStatus) {
if status.Total > 0 {
m.totalFolders = status.Total
}
if status.Current >= 0 {
m.processedFolders = status.Current
}
m.updateProgressDisplay()
}
// addLogMessage добавляет сообщение в лог
func (m *Manager) addLogMessage(message string) {
m.mu.Lock()
defer m.mu.Unlock()
m.logBuffer = append(m.logBuffer, message)
if len(m.logBuffer) > m.maxLogLines {
m.logBuffer = m.logBuffer[1:]
}
m.logView.SetText(strings.Join(m.logBuffer, "\n"))
m.logView.ScrollToEnd()
}
// loadConfig загружает конфигурацию из файла и заполняет форму
func (m *Manager) loadConfig() {
data, err := os.ReadFile("config.yaml")
if err != nil {
return
}
var config ConfigData
if err := yaml.Unmarshal(data, &config); err != nil {
return
}
// Заполняем поля формы данными из конфигурации
m.configForm.GetFormItemByLabel("Исходная папка:").(*tview.InputField).SetText(config.Scanner.SourceDirectory)
m.configForm.GetFormItemByLabel("Целевая папка:").(*tview.InputField).SetText(config.Scanner.TargetDirectory)
m.configForm.GetFormItemByLabel("URL RuTracker:").(*tview.InputField).SetText(config.RuTracker.BaseURL)
m.configForm.GetFormItemByLabel("User Agent:").(*tview.InputField).SetText(config.RuTracker.UserAgent)
m.configForm.GetFormItemByLabel("Задержка запросов (мс):").(*tview.InputField).SetText(strconv.Itoa(config.RuTracker.RequestDelay))
m.configForm.GetFormItemByLabel("Имя пользователя:").(*tview.InputField).SetText(config.RuTracker.Username)
m.configForm.GetFormItemByLabel("Пароль:").(*tview.InputField).SetText(config.RuTracker.Password)
m.configForm.GetFormItemByLabel("Параллельные воркеры:").(*tview.InputField).SetText(strconv.Itoa(config.Processing.ParallelWorkers))
m.configForm.GetFormItemByLabel("Таймаут (сек):").(*tview.InputField).SetText(strconv.Itoa(config.Processing.TimeoutSeconds))
m.configForm.GetFormItemByLabel("Попытки повтора:").(*tview.InputField).SetText(strconv.Itoa(config.Processing.RetryAttempts))
// Устанавливаем уровень логирования
logLevels := []string{"debug", "info", "warn", "error"}
for i, level := range logLevels {
if level == config.Output.LogLevel {
m.configForm.GetFormItemByLabel("Уровень логирования:").(*tview.DropDown).SetCurrentOption(i)
break
}
}
m.configForm.GetFormItemByLabel("Индикатор прогресса:").(*tview.Checkbox).SetChecked(config.Output.ProgressBar)
m.configForm.GetFormItemByLabel("Логирование в файл:").(*tview.Checkbox).SetChecked(config.Output.LogToFile)
m.configForm.GetFormItemByLabel("Имя лог файла:").(*tview.InputField).SetText(config.Output.LogFileName)
m.configForm.GetFormItemByLabel("Макс размер лога (МБ):").(*tview.InputField).SetText(strconv.Itoa(config.Output.LogMaxSizeMB))
}
// saveConfig сохраняет конфигурацию из формы в файл
func (m *Manager) saveConfig() {
var config ConfigData
// Получаем данные из формы
config.Scanner.SourceDirectory = m.configForm.GetFormItemByLabel("Исходная папка:").(*tview.InputField).GetText()
config.Scanner.TargetDirectory = m.configForm.GetFormItemByLabel("Целевая папка:").(*tview.InputField).GetText()
config.RuTracker.BaseURL = m.configForm.GetFormItemByLabel("URL RuTracker:").(*tview.InputField).GetText()
config.RuTracker.UserAgent = m.configForm.GetFormItemByLabel("User Agent:").(*tview.InputField).GetText()
if delay, err := strconv.Atoi(m.configForm.GetFormItemByLabel("Задержка запросов (мс):").(*tview.InputField).GetText()); err == nil {
config.RuTracker.RequestDelay = delay
}
config.RuTracker.Username = m.configForm.GetFormItemByLabel("Имя пользователя:").(*tview.InputField).GetText()
config.RuTracker.Password = m.configForm.GetFormItemByLabel("Пароль:").(*tview.InputField).GetText()
if workers, err := strconv.Atoi(m.configForm.GetFormItemByLabel("Параллельные воркеры:").(*tview.InputField).GetText()); err == nil {
config.Processing.ParallelWorkers = workers
}
if timeout, err := strconv.Atoi(m.configForm.GetFormItemByLabel("Таймаут (сек):").(*tview.InputField).GetText()); err == nil {
config.Processing.TimeoutSeconds = timeout
}
if retries, err := strconv.Atoi(m.configForm.GetFormItemByLabel("Попытки повтора:").(*tview.InputField).GetText()); err == nil {
config.Processing.RetryAttempts = retries
}
// Получаем уровень логирования
_, logLevel := m.configForm.GetFormItemByLabel("Уровень логирования:").(*tview.DropDown).GetCurrentOption()
config.Output.LogLevel = logLevel
config.Output.ProgressBar = m.configForm.GetFormItemByLabel("Индикатор прогресса:").(*tview.Checkbox).IsChecked()
config.Output.LogToFile = m.configForm.GetFormItemByLabel("Логирование в файл:").(*tview.Checkbox).IsChecked()
config.Output.LogFileName = m.configForm.GetFormItemByLabel("Имя лог файла:").(*tview.InputField).GetText()
if maxSize, err := strconv.Atoi(m.configForm.GetFormItemByLabel("Макс размер лога (МБ):").(*tview.InputField).GetText()); err == nil {
config.Output.LogMaxSizeMB = maxSize
}
// Сохраняем в файл
data, err := yaml.Marshal(&config)
if err != nil {
return
}
os.WriteFile("config.yaml", data, 0644)
m.switchToMain()
}
// Методы переключения страниц
func (m *Manager) switchToMain() {
m.currentScreen = entities.ScreenMainMenu
m.pages.SwitchToPage("main")
m.app.SetFocus(m.menuList)
}
func (m *Manager) switchToProcessing() {
m.currentScreen = entities.ScreenProcessing
m.pages.SwitchToPage("processing")
m.app.SetFocus(m.logView)
}
func (m *Manager) switchToConfig() {
m.loadConfig()
m.currentScreen = entities.ScreenSettings
m.pages.SwitchToPage("config")
m.app.SetFocus(m.configForm)
}
// CountAudiobookFolders подсчитывает количество книг (папок верхнего уровня с аудиофайлами)
func (m *Manager) CountAudiobookFolders(sourceDir string) int {
count := 0
entries, err := os.ReadDir(sourceDir)
if err != nil {
return 0
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
bookDir := filepath.Join(sourceDir, entry.Name())
// Считаем книгой папку, в которой есть хотя бы один mp3 во вложенности или metadata.json
if folderHasBookContent(bookDir) {
count++
}
}
return count
}
// folderHasBookContent проверяет, есть ли в папке аудиокнига (mp3 или metadata.json) на любом уровне вложенности
func folderHasBookContent(dir string) bool {
found := false
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || found {
return nil
}
if d.IsDir() {
return nil
}
name := strings.ToLower(d.Name())
if strings.HasSuffix(name, ".mp3") || name == "metadata.json" {
found = true
}
return nil
})
return found
}