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 }