Files
jellybit/internal/tgbot/render.go
T
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.

Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.

Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).

Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:25:00 +03:00

235 lines
7.3 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 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]) + "…"
}