Files
GenAudioBookInfo/internal/presentation/tui_logger.go
2026-02-23 22:06:49 +03:00

523 lines
17 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 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)