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