package tgbot import ( "fmt" "path/filepath" "strings" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) // renderCard строит текст и клавиатуру карточки по состоянию задачи. func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { id := rd.Download.ID state := rd.Download.State switch state { case store.StateReview, store.StateDeferred: return b.reviewCard(rd) case store.StateRecognizing: return "⏳ Распознаю #" + id + "…", b.webOnly(id) case store.StateLinking: return "⏳ Раскладываю #" + id + "…", nil case store.StateDone: return b.renderDone(rd), b.webOnly(id) default: text := fmt.Sprintf("Задача #%s — %s.", id, state) if msg := rd.Download.ErrorMsg.String; msg != "" { text += "\n" + msg } // failed/stuck — даём кнопку повтора; остальное только «в вебе». if state == store.StateFailed || state == store.StateStuck { return text, b.retryKeyboard(id) } return text, b.webOnly(id) } } func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { id := rd.Download.ID var sb strings.Builder fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id) if src := contextOrSource(rd); src != "" { fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80)) } fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd)) if base := baseLine(rd.Recognition); base != "" { fmt.Fprintf(&sb, "База: %s\n", base) } if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 { fmt.Fprintf(&sb, "Причины: %s\n", strings.Join(reasons, " · ")) } if n := len(rd.Preview); n > 0 { fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst)) } return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd) } func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup { id := rd.Download.ID sid := id var row1 []tgbotapi.InlineKeyboardButton if len(rd.Preview) > 0 { row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid)) } row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type)))) row2 := tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid), tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid), ) var row3 []tgbotapi.InlineKeyboardButton if url := b.reviewURL(id); url != "" { row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) } row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid)) kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3) return &kb } // renderDone — короткое сообщение о готовности. func (b *Bot) renderDone(rd *worker.ReviewData) string { title := rd.Plan.Title if title == "" { title = "#" + rd.Download.ID } n := len(rd.Preview) if n == 0 { return fmt.Sprintf("✅ Готово: «%s» разложен.", title) } return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n) } // renderDesync — уведомление о рассинхроне (источник/цель удалены вручную). func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string { title := rd.Plan.Title if title == "" { title = "#" + rd.Download.ID } switch event { case worker.EventTargetMissing: return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title) case worker.EventOrphaned: return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title) default: return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title) } } // renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора. func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { id := rd.Download.ID var sb strings.Builder verb := "не удалась" if rd.Download.State == store.StateStuck { verb = "зависла" } fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb) if code := rd.Download.ErrorCode.String; code != "" { fmt.Fprintf(&sb, " (%s)", code) } sb.WriteString(".") if msg := rd.Download.ErrorMsg.String; msg != "" { sb.WriteString("\n") sb.WriteString(msg) } if src := contextOrSource(rd); src != "" { fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80)) } return sb.String(), b.retryKeyboard(id) } // retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб. func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup { row := []tgbotapi.InlineKeyboardButton{ tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id), } if url := b.reviewURL(id); url != "" { row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) } kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...)) return &kb } func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup { url := b.reviewURL(id) if url == "" { return nil } kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url), )) return &kb } func (b *Bot) reviewURL(id string) string { if b.webBase == "" { return "" } return b.webBase + "/review/" + id } // --- мелкие хелперы --- func guessLine(rd *worker.ReviewData) string { emoji, kind := "🎬", "фильм" if rd.Plan.Type == "series" { emoji, kind = "📺", "сериал" } title := rd.Plan.Title if title == "" { title = "не распознано" } s := fmt.Sprintf("%s %s «%s»", emoji, kind, title) if rd.Plan.Year != 0 { s += fmt.Sprintf(" (%d)", rd.Plan.Year) } return s } func baseLine(rec *store.Recognition) string { if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" { return "нет матча" } if rec.ProviderID.Valid && rec.ProviderID.String != "" { return rec.Provider.String + " " + rec.ProviderID.String } return rec.Provider.String } func contextOrSource(rd *worker.ReviewData) string { if c := strings.TrimSpace(rd.Download.Context); c != "" { return firstLine(c) } return rd.Download.SourceRef } func oppositeType(t string) string { if t == "series" { return "movie" } return "series" } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } func tailPath(p string) string { dir, file := filepath.Split(p) parent := filepath.Base(strings.TrimRight(dir, "/")) if parent == "." || parent == "/" || parent == "" { return file } return parent + "/" + file } func shorten(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }