Приём: дедуп по 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:
av
2026-07-10 20:15:37 +03:00
co-authored by Claude Opus 4.8
parent b8657120fe
commit 1369a9cabe
26 changed files with 1157 additions and 68 deletions
+29 -2
View File
@@ -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 = "Меняю тип…"