// Package presentation — Bubbletea TUI-логгер. // Верхняя панель: прогресс-бар (N/Total книг) + спиннер + последние события. // Нижняя панель: полный цветной лог всех точек алгоритма. // Цветовая схема Dracula взята из deploy/main.go. package presentation import ( "fmt" "strings" "time" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/fofanov/genaudiobookinfo/internal/domain" ) // ═══════════════════════════════════════════════════════════════════════ // Цвета и стили (Dracula, из deploy/main.go) // ═══════════════════════════════════════════════════════════════════════ var ( tuiColorCyan = lipgloss.Color("#00FFFF") tuiColorYellow = lipgloss.Color("#FFFF00") tuiColorRed = lipgloss.Color("#FF5555") tuiColorGreen = lipgloss.Color("#50FA7B") tuiColorGray = lipgloss.Color("#6272A4") tuiColorWhite = lipgloss.Color("#F8F8F2") tuiColorBlue = lipgloss.Color("#6699FF") tuiStyleInfo = lipgloss.NewStyle().Foreground(tuiColorCyan) tuiStyleWarn = lipgloss.NewStyle().Foreground(tuiColorYellow).Bold(true) tuiStyleError = lipgloss.NewStyle().Foreground(tuiColorRed).Bold(true) tuiStyleSuccess = lipgloss.NewStyle().Foreground(tuiColorGreen) tuiStyleDebug = lipgloss.NewStyle().Foreground(tuiColorGray) tuiStyleTime = lipgloss.NewStyle().Foreground(tuiColorGray) tuiStyleStep = lipgloss.NewStyle().Foreground(tuiColorWhite).Bold(true) tuiStyleHeader = lipgloss.NewStyle().Foreground(tuiColorBlue).Bold(true).Padding(0, 1) tuiStyleVersion = lipgloss.NewStyle().Foreground(tuiColorCyan) tuiStyleDone = lipgloss.NewStyle().Foreground(tuiColorGreen).Bold(true) ) // ═══════════════════════════════════════════════════════════════════════ // Типы // ═══════════════════════════════════════════════════════════════════════ type tuiLevel int const ( tuiLevelInfo tuiLevel = iota tuiLevelWarn tuiLevelError tuiLevelSuccess tuiLevelDebug ) type tuiLogEntry struct { Time time.Time Level tuiLevel Message string } // tuiDoneMsg — отправляется через program.Send когда обработка завершена. type tuiDoneMsg struct{ err error } // tuiAutoQuitMsg — отправляется с задержкой для авто-выхода после done. type tuiAutoQuitMsg struct{} const ( tuiMaxLogs = 300 tuiRecentCount = 5 tuiTickMs = 80 * time.Millisecond ) // ═══════════════════════════════════════════════════════════════════════ // Bubbletea model // ═══════════════════════════════════════════════════════════════════════ type tuiModel struct { width int height int progress progress.Model spinner spinner.Model logs []tuiLogEntry logChan <-chan tuiLogEntry bookDone <-chan bool // true=ok, false=error — для счётчика total int completed int startTime time.Time inPath string outPath string apiURL string workers int version string done bool cancelFn func() // вызывается при выходе пользователем во время обработки } type tuiTickMsg time.Time func tuiTick() tea.Cmd { return tea.Tick(tuiTickMs, func(t time.Time) tea.Msg { return tuiTickMsg(t) }) } func (m tuiModel) Init() tea.Cmd { return tea.Batch( tea.EnterAltScreen, m.spinner.Tick, tuiTick(), ) } func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": if m.cancelFn != nil { m.cancelFn() } return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height pw := m.width - 16 if pw < 20 { pw = 20 } m.progress.Width = pw case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) case tuiTickMsg: m.drain() cmds = append(cmds, tuiTick()) case tuiDoneMsg: m.done = true m.drain() // добираем всё оставшееся if msg.err != nil { m.addLog(tuiLogEntry{Time: time.Now(), Level: tuiLevelError, Message: fmt.Sprintf("Обработка завершена с ошибкой: %v", msg.err)}) } else { m.addLog(tuiLogEntry{Time: time.Now(), Level: tuiLevelSuccess, Message: fmt.Sprintf("Все книги обработаны. Итого: %d", m.total)}) } cmds = append(cmds, tea.Tick(1400*time.Millisecond, func(t time.Time) tea.Msg { return tuiAutoQuitMsg{} })) case tuiAutoQuitMsg: return m, tea.Quit } return m, tea.Batch(cmds...) } func (m *tuiModel) drain() { for { select { case e := <-m.logChan: m.addLog(e) case <-m.bookDone: m.completed++ default: return } } } func (m *tuiModel) addLog(e tuiLogEntry) { m.logs = append(m.logs, e) if len(m.logs) > tuiMaxLogs { m.logs = m.logs[len(m.logs)-tuiMaxLogs:] } } // ═══════════════════════════════════════════════════════════════════════ // View — верхняя + нижняя панели // ═══════════════════════════════════════════════════════════════════════ func (m tuiModel) View() string { topH := m.height / 3 if topH < 10 { topH = 10 } botH := m.height - topH - 1 if botH < 3 { botH = 3 } return m.renderTop(topH) + m.renderBottom(botH) } func (m tuiModel) renderTop(height int) string { var b strings.Builder // ── Строка 1: заголовок + версия + время ─────────────────────────── elapsed := time.Since(m.startTime).Round(time.Second) var titleStr string if m.version != "" { titleStr = tuiStyleHeader.Render("GenAudioBookInfo") + " " + tuiStyleVersion.Render("v"+m.version) } else { titleStr = tuiStyleHeader.Render("GenAudioBookInfo") } b.WriteString(fmt.Sprintf(" %s %s\n", titleStr, tuiStyleTime.Render(elapsed.String()))) // ── Строка 2: пути ─────────────────────────────────────────────── b.WriteString(fmt.Sprintf(" %s %s → %s %s\n\n", tuiStyleDebug.Render("воркеры:"), tuiStyleDebug.Render(fmt.Sprintf("%d", m.workers)), tuiStyleDebug.Render(m.inPath), tuiStyleDebug.Render(m.outPath))) // ── Строка 3: спиннер + статус ──────────────────────────────────── var statusLine string if m.done { statusLine = fmt.Sprintf(" %s %s", tuiStyleDone.Render("✓"), tuiStyleDone.Render("Обработка завершена")) } else { statusLine = fmt.Sprintf(" %s %s", m.spinner.View(), tuiStyleStep.Render("Обработка аудиокниг")) } b.WriteString(statusLine + "\n") // ── Строка 4: прогресс-бар + счётчик ───────────────────────────── var pct float64 if m.total > 0 { pct = float64(m.completed) / float64(m.total) } bar := m.progress.ViewAs(pct) countStr := tuiStyleStep.Render(fmt.Sprintf("%d / %d", m.completed, m.total)) pctStr := tuiStyleTime.Render(fmt.Sprintf("%d%%", int(pct*100))) b.WriteString(fmt.Sprintf(" %s %s %s\n\n", bar, countStr, pctStr)) // ── Строка 5+: последние N событий ─────────────────────────────── b.WriteString(tuiStyleTime.Render(" Последние события:\n")) start := len(m.logs) - tuiRecentCount if start < 0 { start = 0 } shown := 0 for _, e := range m.logs[start:] { icon, style := tuiIconStyle(e.Level) timeStr := e.Time.Format("15:04:05") msg := tuiTruncate(e.Message, m.width-22) b.WriteString(fmt.Sprintf(" %s %s %s\n", style.Render(icon), tuiStyleTime.Render(timeStr), style.Render(msg))) shown++ } for i := shown; i < tuiRecentCount; i++ { b.WriteString("\n") } // ── Подгонка высоты ────────────────────────────────────────────── lines := strings.Count(b.String(), "\n") for lines < height { b.WriteString("\n") lines++ } return b.String() } func (m tuiModel) renderBottom(height int) string { var b strings.Builder // Разделитель sep := tuiStyleHeader.Render(" Логи") + tuiStyleDebug.Render(strings.Repeat("─", max(m.width-10, 10))) b.WriteString(sep + "\n") logH := height - 1 if logH < 1 { logH = 1 } startIdx := len(m.logs) - logH if startIdx < 0 { startIdx = 0 } shown := 0 for _, e := range m.logs[startIdx:] { b.WriteString(m.formatLog(e) + "\n") shown++ } for i := shown; i < logH; i++ { b.WriteString("\n") } return b.String() } func (m tuiModel) formatLog(e tuiLogEntry) string { timeStr := e.Time.Format("15:04:05") tag, style := tuiLevelTag(e.Level) msg := tuiTruncate(e.Message, m.width-25) return fmt.Sprintf(" %s %s %s", tuiStyleTime.Render(timeStr), style.Render(tag), style.Render(msg)) } // ═══════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════ func tuiIconStyle(l tuiLevel) (string, lipgloss.Style) { switch l { case tuiLevelSuccess: return "+", tuiStyleSuccess case tuiLevelWarn: return "!", tuiStyleWarn case tuiLevelError: return "x", tuiStyleError case tuiLevelDebug: return "·", tuiStyleDebug default: return ">", tuiStyleInfo } } func tuiLevelTag(l tuiLevel) (string, lipgloss.Style) { switch l { case tuiLevelInfo: return "[INFO ]", tuiStyleInfo case tuiLevelWarn: return "[WARN ]", tuiStyleWarn case tuiLevelError: return "[ERROR]", tuiStyleError case tuiLevelSuccess: return "[OK ]", tuiStyleSuccess case tuiLevelDebug: return "[DEBUG]", tuiStyleDebug default: return "[INFO ]", tuiStyleInfo } } func tuiTruncate(s string, maxLen int) string { if maxLen <= 0 || len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } func max(a, b int) int { if a > b { return a } return b } // ═══════════════════════════════════════════════════════════════════════ // TUILogger — реализует domain.ProcessLogger // ═══════════════════════════════════════════════════════════════════════ // TUILogger — полноэкранный TUI-логгер на базе Bubbletea. // Верхняя панель: прогресс-бар N/Total + спиннер + последние события. // Нижняя панель: подробный хронологический лог всего процесса. type TUILogger struct { logChan chan tuiLogEntry bookDoneCh chan bool program *tea.Program total int inPath string outPath string apiURL string workers int version string } // NewTUILogger создаёт TUILogger. func NewTUILogger(total int, inPath, outPath, apiURL, version string, workers int) *TUILogger { return &TUILogger{ logChan: make(chan tuiLogEntry, 500), bookDoneCh: make(chan bool, 200), total: total, inPath: inPath, outPath: outPath, apiURL: apiURL, workers: workers, version: version, } } // Run запускает TUI (блокирует текущую горутину). // cancelFn вызывается при нажатии q / Ctrl+C пользователем. func (l *TUILogger) Run(cancelFn func()) { pw := 60 prog := progress.New( progress.WithDefaultGradient(), progress.WithWidth(pw), progress.WithoutPercentage(), ) spin := spinner.New() spin.Spinner = spinner.Dot spin.Style = lipgloss.NewStyle().Foreground(tuiColorCyan) m := tuiModel{ width: 120, height: 40, progress: prog, spinner: spin, logs: make([]tuiLogEntry, 0, tuiMaxLogs), logChan: l.logChan, bookDone: l.bookDoneCh, total: l.total, startTime: time.Now(), inPath: l.inPath, outPath: l.outPath, apiURL: l.apiURL, workers: l.workers, version: l.version, cancelFn: cancelFn, } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion(), ) l.program = p _, _ = p.Run() } // SetDone сигнализирует TUI об окончании обработки. func (l *TUILogger) SetDone(err error) { if l.program != nil { l.program.Send(tuiDoneMsg{err: err}) } } // send отправляет запись в лог-канал (non-blocking). func (l *TUILogger) send(level tuiLevel, msg string) { e := tuiLogEntry{Time: time.Now(), Level: level, Message: msg} select { case l.logChan <- e: default: } } // ═══════════════════════════════════════════════════════════════════════ // domain.ProcessLogger — все точки алгоритма // ═══════════════════════════════════════════════════════════════════════ // LogStart — начало обработки книги. func (l *TUILogger) LogStart(folderName string) { l.send(tuiLevelInfo, fmt.Sprintf("[%s] ▶ Начало обработки", folderName)) } // LogExtraction — шаг 1: извлечение тегов из аудиофайла. func (l *TUILogger) LogExtraction() { l.send(tuiLevelDebug, " → [Шаг 1] Извлечение тегов...") } // LogLLMValidation — шаг 3: LLM-валидация метаданных. func (l *TUILogger) LogLLMValidation() { l.send(tuiLevelDebug, " → [Шаг 3] LLM: отправка запроса на валидацию...") } // LogSearch — шаг 2: разбор имени папки и поиск на трекерах. func (l *TUILogger) LogSearch() { l.send(tuiLevelDebug, " → [Шаг 2] Поиск на трекерах через TorrAPI...") } // LogWrite — шаг 4: запись метаданных и перенос файлов. func (l *TUILogger) LogWrite() { l.send(tuiLevelDebug, " → [Шаг 4] Создание metadata.json + перенос файлов...") } // LogCoverDownload — шаг 5: скачивание обложки. func (l *TUILogger) LogCoverDownload() { l.send(tuiLevelDebug, " → [Шаг 5] Скачивание обложки (poster)...") } // LogComplete — книга обработана успешно. func (l *TUILogger) LogComplete(folderName string) { l.send(tuiLevelSuccess, fmt.Sprintf("[%s] ✓ Обработано успешно", folderName)) select { case l.bookDoneCh <- true: default: } } // LogError — ошибка при обработке книги. func (l *TUILogger) LogError(folderName string, err error) { l.send(tuiLevelError, fmt.Sprintf("[%s] ✗ Ошибка: %v", folderName, err)) select { case l.bookDoneCh <- false: default: } } // LogWarning — предупреждение (неожиданное, но не критичное событие). func (l *TUILogger) LogWarning(message string) { l.send(tuiLevelWarn, message) } // LogInfo — штатное информационное событие. func (l *TUILogger) LogInfo(message string) { l.send(tuiLevelInfo, message) } // Finish — завершение логгера (TUI завершается через SetDone/автовыход). func (l *TUILogger) Finish() {} // Compile-time check: TUILogger должен реализовывать domain.ProcessLogger. var _ domain.ProcessLogger = (*TUILogger)(nil)