529 lines
18 KiB
Go
529 lines
18 KiB
Go
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
|
||
}
|