Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
7.4 KiB
Go
238 lines
7.4 KiB
Go
package tgbot
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strconv"
|
||
"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 "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
|
||
case store.StateLinking:
|
||
return "⏳ Раскладываю #" + itoa(id) + "…", nil
|
||
case store.StateDone:
|
||
return b.renderDone(rd), b.webOnly(id)
|
||
default:
|
||
text := fmt.Sprintf("Задача #%d — %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, "🟡 Нужно подтверждение #%d\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 := itoa(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 = "#" + itoa(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 = "#" + itoa(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, "❌ Задача #%d %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 int64) *tgbotapi.InlineKeyboardMarkup {
|
||
row := []tgbotapi.InlineKeyboardButton{
|
||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(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 int64) *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 int64) string {
|
||
if b.webBase == "" {
|
||
return ""
|
||
}
|
||
return b.webBase + "/review/" + itoa(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]) + "…"
|
||
}
|
||
|
||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|