Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.
- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
привязывается к существующей записи (спящей, без обращения к qBittorrent),
а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
дедупе ведёт на страницу существующей записи.
Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-2
@@ -17,6 +17,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ type Reviewer interface {
|
||||
Cancel(ctx context.Context, id string) error
|
||||
Retry(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
Dismiss(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Config — параметры бота.
|
||||
@@ -267,8 +269,20 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
|
||||
return
|
||||
}
|
||||
if res.Deduplicated {
|
||||
// Дубль на уже активную задачу: новую загрузку не заводим, лишь сообщаем.
|
||||
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
|
||||
// Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую»
|
||||
// desync-запись (target_missing/orphaned) — у последней действие вперёд
|
||||
// не «ждите», а «привяжите заново или закройте».
|
||||
switch res.State {
|
||||
case store.StateTargetMissing:
|
||||
// Источник жив, цель удалена — из target_missing доступна перепривязка.
|
||||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", res.DownloadID), nil)
|
||||
case store.StateOrphaned:
|
||||
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
|
||||
// добавить заново (тогда приём заведёт свежую загрузку).
|
||||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", res.DownloadID), nil)
|
||||
default:
|
||||
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
|
||||
@@ -327,6 +341,19 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
case "delete_confirm":
|
||||
err = b.reviewer.Delete(ctx, id)
|
||||
note = "Удаляю…"
|
||||
case "dismiss":
|
||||
// Первый шаг: подтверждение. Стоп-кран лишь меняет статус (файлы/раздачу
|
||||
// не трогает), но убирает запись из активной — подтверждаем сознательно.
|
||||
b.answer(cq.ID, "")
|
||||
b.editMarkup(chatID, msgID, b.dismissConfirmKeyboard(id))
|
||||
return
|
||||
case "dismiss_cancel":
|
||||
b.answer(cq.ID, "Отменено")
|
||||
b.refreshCard(ctx, chatID, msgID, id)
|
||||
return
|
||||
case "dismiss_confirm":
|
||||
err = b.reviewer.Dismiss(ctx, id)
|
||||
note = "Закрываю…"
|
||||
case "type":
|
||||
err = b.reviewer.SetType(ctx, id, val)
|
||||
note = "Меняю тип…"
|
||||
|
||||
@@ -62,14 +62,15 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
||||
}
|
||||
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applied []string
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
deferred []string
|
||||
canceled []string
|
||||
retried []string
|
||||
deleted []string
|
||||
data *worker.ReviewData
|
||||
applied []string
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
deferred []string
|
||||
canceled []string
|
||||
retried []string
|
||||
deleted []string
|
||||
dismissed []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
@@ -109,6 +110,10 @@ func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
|
||||
f.dismissed = append(f.dismissed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
@@ -179,6 +184,23 @@ func TestBot_IngestDeduplicated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп на «спящую» desync-запись (target_missing) → сообщение зовёт привязать
|
||||
// заново/закрыть, а не «дубль активной».
|
||||
func TestBot_IngestDeduplicatedDesync(t *testing.T) {
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
ing.res = ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}
|
||||
|
||||
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
|
||||
|
||||
if len(api.sent) != 1 {
|
||||
t.Fatalf("sent = %+v", api.sent)
|
||||
}
|
||||
txt := api.sent[0].text
|
||||
if !strings.Contains(txt, "без цели") || !strings.Contains(txt, tid) {
|
||||
t.Errorf("ожидалось сообщение о записи без цели с #%s, got %q", tid, txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBot_DeniesUnknownUser(t *testing.T) {
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
||||
|
||||
@@ -160,6 +160,8 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
row := []tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||||
// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
|
||||
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
|
||||
}
|
||||
if url := b.reviewURL(id); url != "" {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||
@@ -169,14 +171,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
}
|
||||
|
||||
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
|
||||
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
|
||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||||
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||||
// ведёт на подтверждение.
|
||||
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
var row []tgbotapi.InlineKeyboardButton
|
||||
if url := b.reviewURL(id); url != "" {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||
}
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
|
||||
row = append(row,
|
||||
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
|
||||
tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id),
|
||||
)
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
|
||||
return &kb
|
||||
}
|
||||
@@ -191,6 +197,16 @@ func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в
|
||||
// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены.
|
||||
func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id),
|
||||
tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id),
|
||||
))
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
url := b.reviewURL(id)
|
||||
if url == "" {
|
||||
|
||||
Reference in New Issue
Block a user