From e4ba057abb897635d345c0b95e749827dacc3cec Mon Sep 17 00:00:00 2001 From: Dmitriy Fofanov Date: Tue, 30 Sep 2025 14:04:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B0=D1=83=D0=B4=D0=B8=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=B8=D0=B3=20=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 28 +- .../usecases/process_audiobooks.go | 38 +- internal/presentation/tui/manager.go | 371 +++++++++++++----- 3 files changed, 317 insertions(+), 120 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 6ce7d28..54151af 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -60,6 +60,10 @@ func main() { rutrackerRepo, logger, ) + // Подключаем репортер прогресса к TUI + processUseCase.SetProgressReporter(func(s entities.ProcessingStatus) { + tuiManager.SendStatusUpdate(s) + }) // Создание процессора для обработки команд processor := &ApplicationProcessor{ @@ -102,36 +106,14 @@ func (p *ApplicationProcessor) StartProcessing() { p.logger.Info("Запуск обработки аудиокниг...") } - // Обновим UI статус - p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{ - Current: 0, - Total: 1, - Status: "Начинаем обработку...", - Error: nil, - }) - - // Запускаем обработку + // Запускаем обработку (репортинг прогресса выполняет use case) if err := p.processUseCase.Execute(p.config); err != nil { if p.logger != nil { p.logger.Error("Ошибка обработки: %v", err) } - p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{ - Current: 0, - Total: 1, - Status: "Ошибка обработки", - Error: err, - }) return } - // Успешное завершение - p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{ - Current: 1, - Total: 1, - Status: "Обработка завершена успешно!", - Error: nil, - }) - if p.logger != nil { p.logger.Success("Обработка аудиокниг завершена успешно") } diff --git a/internal/application/usecases/process_audiobooks.go b/internal/application/usecases/process_audiobooks.go index 1bf3680..619ea5e 100644 --- a/internal/application/usecases/process_audiobooks.go +++ b/internal/application/usecases/process_audiobooks.go @@ -16,6 +16,8 @@ type ProcessAudioBooksUseCase struct { logger repositories.Logger audioBookSvc *services.AudioBookService metadataSvc *services.MetadataService + // Репортер прогресса + progress func(entities.ProcessingStatus) } // NewProcessAudioBooksUseCase создает новый use case @@ -33,6 +35,11 @@ func NewProcessAudioBooksUseCase( } } +// SetProgressReporter задает функцию репортинга прогресса +func (uc *ProcessAudioBooksUseCase) SetProgressReporter(f func(entities.ProcessingStatus)) { + uc.progress = f +} + // Execute выполняет обработку аудиокниг func (uc *ProcessAudioBooksUseCase) Execute(config *entities.Config) error { uc.logger.Info("🚀 Начало процесса обработки аудиокниг") @@ -42,35 +49,58 @@ func (uc *ProcessAudioBooksUseCase) Execute(config *entities.Config) error { audioBooks, err := uc.audioBookRepo.ScanDirectory(config.Scanner.SourceDirectory) if err != nil { uc.logger.Error("❌ Критическая ошибка сканирования: %v", err) + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: 0, Total: 0, Status: "Ошибка сканирования", Error: err}) + } return fmt.Errorf("ошибка сканирования: %w", err) } - uc.logger.Info("📊 Найдено %d аудиокниг", len(audioBooks)) + total := len(audioBooks) + uc.logger.Info("📊 Найдено %d аудиокниг", total) - if len(audioBooks) == 0 { + if total == 0 { uc.logger.Warning("⚠️ Аудиокниги не найдены") + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: 0, Total: 0, Status: "Книги не найдены"}) + } return nil } + // Сообщаем общее количество до начала обработки + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: 0, Total: total, Status: "Начинаем обработку"}) + } + // Авторизация в RuTracker if err := uc.rutrackerRepo.Login(); err != nil { uc.logger.Error("❌ Ошибка авторизации в RuTracker: %v", err) + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: 0, Total: total, Status: "Ошибка авторизации", Error: err}) + } return fmt.Errorf("ошибка авторизации: %w", err) } uc.logger.Success("✅ Авторизация в RuTracker успешна") // Обработка каждой аудиокниги for i, book := range audioBooks { - uc.logger.Info("📋 Обработка книги %d/%d: \"%s\"", i+1, len(audioBooks), book.Title) + uc.logger.Info("📋 Обработка книги %d/%d: \"%s\"", i+1, total, book.Title) if err := uc.processAudioBook(book); err != nil { uc.logger.Error("❌ Ошибка обработки книги \"%s\": %v", book.Title, err) - continue + } + + // Увеличиваем прогресс после завершения обработки книги (успешно или нет) + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: i + 1, Total: total, Status: fmt.Sprintf("Готово %d/%d", i+1, total)}) } uc.logger.Success("✅ Книга \"%s\" обработана", book.Title) } + if uc.progress != nil { + uc.progress(entities.ProcessingStatus{Current: total, Total: total, Status: "Обработка завершена"}) + } + uc.logger.Success("🎉 Обработка завершена успешно!") return nil } diff --git a/internal/presentation/tui/manager.go b/internal/presentation/tui/manager.go index fd987ea..ddf7816 100644 --- a/internal/presentation/tui/manager.go +++ b/internal/presentation/tui/manager.go @@ -2,6 +2,10 @@ package tui import ( "fmt" + "math" + "os" + "path/filepath" + "strconv" "strings" "sync" @@ -9,8 +13,36 @@ import ( "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 @@ -24,20 +56,18 @@ type Manager struct { statusBar *tview.TextView // Компоненты главного меню - menuList *tview.List - infoPanel *tview.TextView + menuList *tview.List // Компоненты обработки - logView *tview.TextView - progressBar *tview.TextView - progressText *tview.TextView + logView *tview.TextView + progressBar *tview.TextView - // Компоненты настроек - settingsForm *tview.Form + // Компоненты конфигурации + configForm *tview.Form - // Компоненты результатов - resultsTable *tview.Table - detailView *tview.TextView + // Счетчики прогресса + totalFolders int + processedFolders int // Состояние logBuffer []string @@ -73,6 +103,7 @@ func (m *Manager) Initialize() { m.initializeComponents() m.setupMainMenu() m.setupProcessingScreen() + m.setupConfigScreen() m.setupSettingsScreen() m.setupResultsScreen() m.setupPages() @@ -126,56 +157,38 @@ func (m *Manager) initializeComponents() { // Создаем систему страниц заранее m.pages = tview.NewPages() - // Создаем главный контейнер: Header | Pages | Status | Footer + // Создаем главный контейнер: Header | Pages | Footer m.mainFlex = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(m.headerBar, 3, 0, false). AddItem(m.pages, 0, 1, true). - AddItem(m.statusBar, 1, 0, false). AddItem(m.footerBar, 1, 0, false) } // setupMainMenu настраивает главное меню func (m *Manager) setupMainMenu() { m.menuList = tview.NewList(). - AddItem("🚀 Начать обработку аудиокниг", "Начать сканирование и обработку аудиокниг", '1', func() { + AddItem("🚀 Обработка аудиокниг", "Начать обработку аудиокниг в указанных папках", '1', func() { m.switchToProcessing() if m.onStartProcessing != nil { go m.onStartProcessing() } }). - AddItem("⚙️ Настройки", "Настройка параметров приложения", '2', func() { - m.switchToSettings() - }). - AddItem("📊 Результаты", "Просмотр результатов обработки", '3', func() { - m.switchToResults() + AddItem("⚙️ Конфигурация", "Настройка параметров приложения", '2', func() { + m.switchToConfig() }). AddItem("❌ Выход", "Завершить работу приложения", 'q', func() { m.app.Stop() }) - m.menuList.SetBorder(true).SetTitle(" Главное меню ") + m.menuList.SetBorder(true).SetTitle(" Выберите задачу ") - m.infoPanel = tview.NewTextView(). - SetDynamicColors(true). - SetWrap(true). - SetText(`[yellow]AudioBook Catalyst[-] + // Увеличиваем размер элементов списка + m.menuList.SetSelectedBackgroundColor(tcell.ColorBlue) + m.menuList.SetSelectedTextColor(tcell.ColorWhite) -Приложение для автоматической обработки аудиокниг: -• Сканирование папок с MP3 файлами -• Поиск метаданных на RuTracker -• Создание файлов metadata.json -• Загрузка обложек - -[grey]Используйте клавиши 1-4 или мышь для навигации[-]`) - - m.infoPanel.SetBorder(true).SetTitle(" Информация ") - - mainMenuFlex := tview.NewFlex(). - AddItem(m.menuList, 0, 1, true). - AddItem(m.infoPanel, 0, 1, false) - - m.pages.AddPage("main", mainMenuFlex, true, true) + // Создаем страницу с меню на весь экран + m.pages.AddPage("main", m.menuList, true, true) } // setupProcessingScreen настраивает экран обработки @@ -191,59 +204,59 @@ func (m *Manager) setupProcessingScreen() { SetTextAlign(tview.AlignCenter) m.progressBar.SetBorder(true).SetTitle(" Прогресс ") - m.progressText = tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetText("[white]Ожидание начала обработки...[-]") - + // Горизонтальный прогресс ниже логов progressFlex := tview.NewFlex(). SetDirection(tview.FlexRow). - AddItem(m.progressBar, 3, 0, false). - AddItem(m.progressText, 1, 0, false) + AddItem(m.progressBar, 3, 0, false) processingFlex := tview.NewFlex(). SetDirection(tview.FlexRow). - AddItem(progressFlex, 4, 0, false). - AddItem(m.logView, 0, 1, true) + AddItem(m.logView, 0, 1, true). + AddItem(progressFlex, 4, 0, false) + + // Инициализируем прогресс-бар текстом по умолчанию + m.updateProgressDisplay() m.pages.AddPage("processing", processingFlex, true, false) } -// setupSettingsScreen настраивает экран настроек -func (m *Manager) setupSettingsScreen() { - m.settingsForm = tview.NewForm(). - AddInputField("Исходная директория:", "", 50, nil, nil). - AddInputField("Целевая директория:", "", 50, nil, nil). - AddInputField("Имя пользователя RuTracker:", "", 30, nil, nil). - AddPasswordField("Пароль RuTracker:", "", 30, '*', nil). - AddDropDown("Уровень логирования:", []string{"info", "debug"}, 0, nil). +// 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() { - // TODO: Сохранить настройки + m.saveConfig() }). AddButton("Отмена", func() { m.switchToMain() }) - m.settingsForm.SetBorder(true).SetTitle(" Настройки ") - m.pages.AddPage("settings", m.settingsForm, true, false) + m.configForm.SetBorder(true).SetTitle(" Конфигурация ") + m.pages.AddPage("config", m.configForm, true, false) +} + +// setupSettingsScreen настраивает экран настроек +func (m *Manager) setupSettingsScreen() { + // Убираем экран настроек, так как он не нужен в упрощенном интерфейсе } // setupResultsScreen настраивает экран результатов func (m *Manager) setupResultsScreen() { - m.resultsTable = tview.NewTable(). - SetSelectable(true, false) - m.resultsTable.SetBorder(true).SetTitle(" Обработанные аудиокниги ") - - m.detailView = tview.NewTextView(). - SetDynamicColors(true). - SetWrap(true) - m.detailView.SetBorder(true).SetTitle(" Детали ") - - resultsFlex := tview.NewFlex(). - AddItem(m.resultsTable, 0, 2, true). - AddItem(m.detailView, 0, 1, false) - - m.pages.AddPage("results", resultsFlex, true, false) + // Убираем экран результатов, так как он не нужен в упрощенном интерфейсе } // setupPages настраивает систему страниц @@ -262,12 +275,7 @@ func (m *Manager) setupPages() { case tcell.KeyF2: m.switchToProcessing() return nil - case tcell.KeyF3: - m.switchToSettings() - return nil - case tcell.KeyF4: - m.switchToResults() - return nil + } return event }) @@ -289,18 +297,68 @@ func (m *Manager) handleUpdates() { } } -// updateProgress обновляет прогресс +// 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 { - progress := float64(status.Current) / float64(status.Total) * 100 - m.progressBar.SetText(fmt.Sprintf("[green]%.1f%% (%d/%d)[-]", progress, status.Current, status.Total)) + m.totalFolders = status.Total } - - if status.Error != nil { - m.progressText.SetText(fmt.Sprintf("[red]Ошибка: %s[-]", status.Error.Error())) - } else { - m.progressText.SetText(fmt.Sprintf("[white]%s[-]", status.Status)) + if status.Current >= 0 { + m.processedFolders = status.Current } + m.updateProgressDisplay() } // addLogMessage добавляет сообщение в лог @@ -317,6 +375,96 @@ func (m *Manager) addLogMessage(message string) { 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 @@ -330,14 +478,51 @@ func (m *Manager) switchToProcessing() { m.app.SetFocus(m.logView) } -func (m *Manager) switchToSettings() { +func (m *Manager) switchToConfig() { + m.loadConfig() m.currentScreen = entities.ScreenSettings - m.pages.SwitchToPage("settings") - m.app.SetFocus(m.settingsForm) + m.pages.SwitchToPage("config") + m.app.SetFocus(m.configForm) } -func (m *Manager) switchToResults() { - m.currentScreen = entities.ScreenResults - m.pages.SwitchToPage("results") - m.app.SetFocus(m.resultsTable) +// 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 }