Приём: дедуп по 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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-10
|
||||
@@ -0,0 +1,204 @@
|
||||
## Context
|
||||
|
||||
Дедуп приёма (`ingest`) держит инвариант «не более одной активной загрузки на
|
||||
infohash», где «активная» = `state NOT IN terminalStates`. Терминальный набор
|
||||
(`done, cancelled, failed, reverted, target_missing, orphaned, deleted`) смешивает
|
||||
две разные ситуации:
|
||||
|
||||
- **«Отработали, забыли»** — `done`/`cancelled`/`failed`/`reverted`/`deleted`.
|
||||
Повторный приём такого инфохэша — осознанное «хочу заново», новая загрузка
|
||||
легитимна (`ingest/spec.md`, сценарий «Повторный приём после завершения»).
|
||||
- **«Держим источник ради незакрытого намерения»** — `target_missing` (источник
|
||||
жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись держит
|
||||
претензию на последнюю копию данных).
|
||||
|
||||
Дедуп трактует обе группы одинаково: терминально → не активна → плоди новую. Во
|
||||
второй группе это рождает близнеца (реальный прод-случай: `target_missing` +
|
||||
`done` на один торрент). Причём усыновление раздачи воркером
|
||||
(`download-tracking`, change `catched-promote-without-readd`) делает близнеца
|
||||
«боевым» — он раскладывается и занимает целевой путь, из-за чего у осиротевшей
|
||||
записи `Привязать заново` упирается в коллизию владения путём.
|
||||
|
||||
Отдельно: закрыть лишнюю `target_missing`-запись сейчас нечем. Единственная
|
||||
команда, убирающая её из активного внимания, — «Удалить», но она **сносит
|
||||
раздачу** в qBittorrent (`deleteFiles=true`), а этого как раз не нужно: раздача
|
||||
общая, её ведёт `done`-близнец.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Повторный приём инфохэша, удерживаемого записью в `target_missing`/`orphaned`,
|
||||
не создаёт новую загрузку, а возвращает существующую (attach), сохраняя приём
|
||||
быстрым и без обращения к qBittorrent.
|
||||
- Пользователь может вручную закрыть **любую** зависшую/спорную загрузку
|
||||
(стоп-кран), ничего не делая с файлами и раздачей.
|
||||
- Инвариант «≤1 активной на infohash» и его атомарные гарды не ослабляются.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Авто-схлопывание/слияние уже существующих дублей фоновой сверкой — сознательно
|
||||
ручной путь.
|
||||
- Изменение логики усыновления раздачи в воркере и общей матрицы «источник ×
|
||||
цель» сверки.
|
||||
- Авто-relink при повторном приёме (это обращение к qBittorrent — противоречит
|
||||
«быстрому приёму без сети»); relink остаётся отдельным явным действием.
|
||||
- Разбор коллизии владения целевым путём при relink `target_missing`, чьи файлы
|
||||
уже разложены другой записью, — эту ситуацию закрывает команда «Закрыть», а не
|
||||
relink.
|
||||
- Любое удаление/создание файлов или снятие раздачи командой «Закрыть» — для
|
||||
этого есть «Удалить». «Закрыть» — чисто смена статуса.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Р1. Дедуп: reingest-blocking states = active ∪ {target_missing, orphaned}
|
||||
|
||||
Вводим понятие «состояний, блокирующих повторный приём»: активные состояния
|
||||
**плюс** `target_missing` и `orphaned`. Поиск дедупа при приёме
|
||||
(`FindActiveByInfohash` → по сути `FindReingestBlockingByInfohash`) ищет запись в
|
||||
любом из этих состояний по любому из хешей и, найдя, возвращает её вместо
|
||||
создания новой. Приоритет — активная (если вдруг есть и активная, и desync-запись
|
||||
на один хеш, что допускает текущий инвариант), иначе desync-запись.
|
||||
|
||||
- Почему не «сделать `target_missing`/`orphaned` активными»: сломает семантику
|
||||
«активность выводится только из state» и потянет за собой сверку, healing,
|
||||
выборки активных. Дедуп — единственное место, которому нужна расширенная
|
||||
оптика; локализуем изменение там.
|
||||
- Атомарность: гард создания (`CreateDownloadIfNoActive`) остаётся про активные —
|
||||
он бэкстоп инварианта «≤1 активной». Расширенная проверка — это read-ветка
|
||||
дедупа ДО создания; она короткозамыкает на attach. Гонка «два приёма
|
||||
одновременно на свежий target_missing» в худшем случае даёт одну лишнюю
|
||||
попытку create, которую по-прежнему отсекает активный гард; близнец на
|
||||
`target_missing` при этом не создаётся, т.к. обе ветки видят одну и ту же
|
||||
desync-запись (она уже в БД, коммитнута ранее).
|
||||
|
||||
### Р2. Attach для desync-записи не воскрешает и не доносит хеши сам по себе
|
||||
|
||||
Ветка attach для `target_missing`/`orphaned` возвращает запись как «спящую,
|
||||
требует relink» (флаг в результате приёма), НЕ переводя её в активное состояние и
|
||||
НЕ вызывая qBittorrent. Донесение недостающих хешей (гибридный торрент) для
|
||||
desync-записи допустимо и безопасно (терминальная запись не «активна», гонки за
|
||||
хеш нет), но подчиняется тому же правилу «не красть хеш у другой активной»
|
||||
(`ingest/spec.md`, «Атомарность возврата…»). Бот/веб сообщают: запись существует,
|
||||
приложите relink или закройте.
|
||||
|
||||
- Почему не авто-relink: relink делает синхронный source-preflight (обращение к
|
||||
qBittorrent) — это нарушает инвариант «синхронный приём не ходит в
|
||||
qBittorrent». Явный relink пользователем сохраняет разделение шагов.
|
||||
|
||||
Почему `failed`/`reverted` НЕ блокирующие (в отличие от `target_missing`/
|
||||
`orphaned`): у них нет удерживаемого источника ради незакрытого намерения —
|
||||
повторный приём осознанно трактуется как **свежая попытка**. Новая активная
|
||||
загрузка забирает хеш, старая терминальная им не владеет; её фоновое
|
||||
самовосстановление (revive `failed`) корректно отклонится активным гардом «infohash
|
||||
занят». Дубля-призрака (как с `target_missing`) при этом не возникает: старая
|
||||
запись остаётся терминальной и не раскладывается повторно. Реализация не
|
||||
переиспользует общий active-хелпер для desync-проверки — расширенная оптика нужна
|
||||
только дедуп-пред-риду (см. Р1, Б-развязка с `ActivateIfNoOtherActive`/
|
||||
`AddInfohashes`).
|
||||
|
||||
### Р3. «Закрыть» = переход в cancelled, без нового статуса (принято)
|
||||
|
||||
Команда «Закрыть» (dismiss) переводит запись в **существующее терминальное
|
||||
`cancelled`** с `error_code`-дискриминатором (`user_dismiss`), человекочитаемой
|
||||
причиной в `error_msg` и логом перехода.
|
||||
|
||||
Почему `cancelled`, а не новый `dismissed`:
|
||||
|
||||
- Прецедент в коде: Delete переиспользует `deleted` + `error_code="user_delete"`
|
||||
и явно постулирует «новый статус вводить SHALL NOT» — терминальный набор
|
||||
завязан на семантику активности, любой новый статус её разъедает и тянет
|
||||
правки во все выборки/сверку.
|
||||
- `cancelled` уже значит «пользователь отказался от этой записи, источник не
|
||||
трогаем», из него доступен relink — естественный safety valve, если передумал.
|
||||
- `cancelled` не входит в reingest-blocking (Р1) → после «Закрыть» повторный
|
||||
приём заведёт свежую загрузку. Это осознанно: запись закрыта, дубля-призрака
|
||||
больше нет.
|
||||
|
||||
Различение причины отмены (закрытие стоп-краном vs. отклонение на ревью) несёт
|
||||
`error_code`, а не отдельный статус.
|
||||
|
||||
### Р4. «Закрыть» — универсальный стоп-кран из любого состояния, кроме deleted (принято)
|
||||
|
||||
Команда доступна из **любого** состояния, кроме `deleted` (строго терминально,
|
||||
сверка его не переоценивает — не воскрешаем граф). На `cancelled` — идемпотентный
|
||||
no-op (самопереход). Инвариант команды: **только меняет статус**, файлы под
|
||||
`paths.*` и раздачу в qBittorrent НЕ трогает.
|
||||
|
||||
- Из `target_missing` (исходный прод-случай) — закрытие инертно (целевых ссылок
|
||||
нет, источник жив и остаётся раздаваться).
|
||||
- Из `done`/`orphaned` — библиотечные хардлинки **сознательно остаются** на месте
|
||||
(не удаляем: «Закрыть» ≠ «Удалить»). Запись перестаёт отслеживаться.
|
||||
- Из активных/`stuck`/`failed`/`deferred` — источник в qBittorrent остаётся как
|
||||
есть (докачивается/раздаётся); мы лишь снимаем запись из внимания.
|
||||
|
||||
Отличие от существующего Cancel/«Отклонить» (review-флоу, только из
|
||||
нетерминальных): «Закрыть» — универсальный стоп-кран, доступный и из терминальных
|
||||
`done`/`failed`/`reverted`/`target_missing`/`orphaned`, и живёт в отдельной danger
|
||||
zone внизу страницы. Оба ведут в `cancelled`; различаются гардом источника и
|
||||
`error_code`.
|
||||
|
||||
Рёбра `allowedTransitions`, которые нужно добавить (у нетерминальных `cancelled`
|
||||
как цель уже есть): `done → cancelled`, `failed → cancelled`,
|
||||
`reverted → cancelled`, `target_missing → cancelled`, `orphaned → cancelled`.
|
||||
После этого `cancelled` — легальная цель из любого состояния, кроме `deleted`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Relink `target_missing` при существующем `done`-близнеце всё ещё упрётся в
|
||||
коллизию пути.** → Ожидаемо и допустимо: правильное действие для лишней
|
||||
записи — «Закрыть», а не relink; коллизия владения путём (`state-reconciliation`,
|
||||
«Занятый путь даёт коллизию») отрабатывает штатно и не портит данные.
|
||||
- **Reingest-blocking расширен → пользователь, реально желающий переснять
|
||||
`target_missing`-торрент заново, получит attach, а не новую загрузку.** →
|
||||
Приемлемо: у него есть relink (вперёд) и «Закрыть» (закрыть и, при желании,
|
||||
переслать снова — новая загрузка заведётся из `cancelled`).
|
||||
- **Гонка двух одновременных приёмов на свежую desync-запись.** → Оба видят уже
|
||||
коммитнутую desync-запись → attach; активный гард отсекает случайный create.
|
||||
Близнец не рождается.
|
||||
- **`error_code=user_dismiss` в `cancelled` смешивает две причины отмены.** →
|
||||
Дискриминатор в `error_code` + `error_msg`/лог различают их; телеметрия по
|
||||
причине доступна без нового статуса.
|
||||
- **«Закрыть» из `done`/`orphaned` оставляет неотслеживаемые хардлинки** под
|
||||
`paths.movies`/`series`, чей `file_link` продолжает «владеть» путём (`cancelled`
|
||||
сверкой не переоценивается). → Осознанный компромисс стоп-крана «только
|
||||
статус»: файлы оставляем как есть, реальную зачистку делает «Удалить». Повторная
|
||||
закачка того же пути упрётся в штатную коллизию владения путём
|
||||
(`state-reconciliation`, «Занятый путь даёт коллизию»), а не в порчу данных.
|
||||
- **«Закрыть» из активных состояний рвёт запись из-под воркера** (напр. в
|
||||
`linking`/`downloading`). → Команды сериализуются воркером под единой
|
||||
блокировкой (как прочие команды ревью) — «Закрыть» применяется как последняя
|
||||
валидная команда, а не посреди операции; частично созданные ссылки остаются, что
|
||||
соответствует контракту «только статус».
|
||||
- **Восстановление `orphaned` через приём — двухшаговое.** `orphaned` (источник
|
||||
пропал) блокирует повторный приём (attach), но relink из `orphaned` не
|
||||
реализован, а приём не добавляет источник в qBittorrent. → Рабочий путь возврата
|
||||
источника: «Закрыть» (→ `cancelled`) → повторный приём (уже не блокируется) →
|
||||
свежая активная загрузка, которую воркер добавит и разложит. Прямой приём без
|
||||
attach создал бы близнеца с коллизией целевого пути (файл `orphaned` ещё на
|
||||
месте), поэтому attach выбран сознательно; транспорты в `orphaned` формулируют
|
||||
действие как «закройте, затем добавьте заново» (не «привяжите заново»). Прямой
|
||||
relink-из-`orphaned` — возможное будущее улучшение вне scope этого change.
|
||||
- **danger zone скрывает завершённую (`done`) запись одним действием.** →
|
||||
Разместить «Закрыть» в отдельной danger zone внизу страницы; для необратимо
|
||||
выглядящих случаев (`done` и прочие терминальные) UI SHOULD запрашивать
|
||||
подтверждение (относительно дёшево — из `cancelled` доступен relink).
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Схема БД не меняется (нет таблиц/столбцов/статусов). Миграции не требуются.
|
||||
- Изменения — код + дельта-спеки; деплой обычным бинарём. Откат — откат бинаря;
|
||||
данные не мигрированы, несовместимости нет.
|
||||
- Обновить граф переходов в тесте (`cancelled` как цель из
|
||||
`done`/`failed`/`reverted`/`target_missing`/`orphaned`) и описание
|
||||
статусов/переходов в `docs/specs/database.md`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Р3 (`cancelled` + `error_code`) и Р4 (универсальный стоп-кран из любого
|
||||
состояния, кроме `deleted`) — **приняты**.
|
||||
- Требует ли «Закрыть» из терминальных/`done` подтверждения в UI (см. риск) —
|
||||
решить на реализации веб-UI.
|
||||
- Тексты для транспортов: формулировка ответа приёма при attach на desync-запись
|
||||
(«существует как #id без цели — привяжите заново или закройте») и подпись кнопки
|
||||
«Закрыть» в веб/Telegram.
|
||||
@@ -0,0 +1,73 @@
|
||||
## Why
|
||||
|
||||
При повторном приёме торрента, у которого уже есть запись в `target_missing`
|
||||
(«разложено, но файлов в библиотеке нет»), рождается загрузка-близнец: дедуп
|
||||
приёма ищет только **активную** задачу по инфохэшу, а `target_missing`
|
||||
терминально — активной нет, заводится новая загрузка, воркер усыновляет
|
||||
присутствующую в qBittorrent раздачу и раскладывает её. В итоге на один торрент
|
||||
две записи (`done` + `target_missing`), причём у осиротевшей единственное
|
||||
действие «Привязать заново» упрётся в уже занятый целевой путь. Пользователю
|
||||
нечем аккуратно закрыть лишнюю запись, не снося при этом раздачу.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Предотвращение дубля на приёме.** Повторный приём инфохэша, которым владеет
|
||||
запись в `target_missing` или `orphaned`, SHALL привязываться к этой записи
|
||||
(возврат существующей, `Deduplicated`), а не заводить новую загрузку. Критерий
|
||||
дедупа расширяется с «активной» до «активной **или** удерживающей источник
|
||||
ради незакрытого намерения» (`target_missing`/`orphaned`). `done` из дедупа
|
||||
сознательно остаётся размножаемым (повторный приём завершённого = осознанное
|
||||
«хочу заново»). Приём остаётся быстрым: qBittorrent не трогаем, авто-relink не
|
||||
запускаем — пользователю сообщается, что запись существует и её нужно привязать
|
||||
заново.
|
||||
- **Команда «Закрыть» — универсальный стоп-кран.** Добавляется ручная команда,
|
||||
доступная из **любого** состояния (кроме `deleted`) во всех транспортах,
|
||||
переводящая запись в терминальное `cancelled` (с `error_code`-дискриминатором)
|
||||
и убирающая её из активного списка. Команда **только меняет статус**: файлы под
|
||||
`paths.*` не трогает (в т.ч. из `done`/`orphaned` библиотечные хардлинки
|
||||
сознательно остаются на месте) и раздачу в qBittorrent не снимает (в отличие от
|
||||
«Удалить»). Размещается в отдельной danger zone внизу страницы. Так
|
||||
пользователь закрывает лишнего близнеца, а заодно получает страховку для любой
|
||||
зависшей/спорной загрузки.
|
||||
|
||||
Явно вне scope: авто-схлопывание дублей в фоновой сверке (выбран ручной путь);
|
||||
изменение логики усыновления в воркере; введение нового статуса (переиспользуем
|
||||
`cancelled`, как Delete переиспользует `deleted`); удаление/создание каких-либо
|
||||
файлов или раздач командой «Закрыть».
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- нет новых capability -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `ingest`: критерий дедупликации приёма расширяется — блокирующими повторный
|
||||
приём становятся не только активные, но и `target_missing`/`orphaned` записи
|
||||
(attach вместо создания новой); повторный приём завершённой (`done`) остаётся
|
||||
созданием новой. Модифицируются требования «Дедупликация приёма по любому из
|
||||
хешей» и «Приём источника и заведение загрузки» (терминология «блокирующей»
|
||||
задачи). Требование «Приём из .torrent-файла» текст НЕ правит: оно уже явно
|
||||
делегирует критерий модифицированному требованию через inline-ссылку — оба
|
||||
дедуп-упоминания там наследуют расширенный критерий без риска дрейфа.
|
||||
- `state-reconciliation`: добавляется пользовательская команда «Закрыть»
|
||||
(dismiss) из любого состояния (кроме `deleted`) в терминальное `cancelled`,
|
||||
ничего не делающая с файлами и раздачей; фиксируется её отличие от «Удалить» и
|
||||
новые рёбра перехода `<любое> → cancelled` (в т.ч. из терминальных
|
||||
`done`/`failed`/`reverted`/`target_missing`/`orphaned`).
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: `internal/ingest/ingest.go` (`Ingest`/`attached` — ветка attach для
|
||||
desync-записей, флаг «нужен relink»), `internal/store/download.go`
|
||||
(критерий поиска дедупа: reingest-blocking states = active ∪
|
||||
`{target_missing, orphaned}`; новое ребро `allowedTransitions`
|
||||
`target_missing → cancelled`; `error_code`-дискриминатор dismiss),
|
||||
`internal/worker/review.go` (обработчик команды «Закрыть» рядом с
|
||||
Delete/Undo/Relink — только setState, без файлов и qBittorrent), веб-UI
|
||||
(danger zone внизу страницы с кнопкой «Закрыть»), `internal/tgbot`.
|
||||
- Данные: новых таблиц/столбцов нет; терминальный набор не меняется (dismiss →
|
||||
существующее `cancelled`). Обновляется граф переходов и, при необходимости,
|
||||
описание статусов в `docs/specs/database.md`.
|
||||
- Инварианты безопасности данных: «Закрыть» источник неприкосновенен —
|
||||
qBittorrent не вызывается, файлы под `paths.downloads`/`movies`/`series` не
|
||||
трогаются.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём источника и заведение загрузки
|
||||
|
||||
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
|
||||
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
|
||||
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
|
||||
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
|
||||
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
|
||||
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
состояние»).
|
||||
|
||||
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||
отображаемое имя (потенциально медленный LLM): и добавление источника в
|
||||
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
|
||||
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
|
||||
qBittorrent».
|
||||
|
||||
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
|
||||
более одной активной загрузки на infohash» наравне с прочими активными.
|
||||
|
||||
#### Scenario: Быстрый приём magnet
|
||||
|
||||
- **GIVEN** валидная magnet-ссылка и контекст
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** создаётся `download` в состоянии `catched` с записями
|
||||
`download_infohash`
|
||||
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
|
||||
|
||||
#### Scenario: Дубль по активной задаче на быстром пути
|
||||
|
||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
|
||||
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
|
||||
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
|
||||
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
|
||||
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
|
||||
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
|
||||
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
|
||||
новую загрузку.
|
||||
|
||||
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
|
||||
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
|
||||
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
|
||||
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
|
||||
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
|
||||
перепривязки либо закрытия.
|
||||
|
||||
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
|
||||
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
|
||||
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
|
||||
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
|
||||
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
|
||||
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
|
||||
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
|
||||
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
|
||||
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
|
||||
NOT — активность выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
- **GIVEN** активная загрузка с infohash `h`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём при записи без цели
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
|
||||
удалена)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
|
||||
qBittorrent обращения нет)
|
||||
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
|
||||
заново или закрыть
|
||||
|
||||
#### Scenario: Повторный приём при осиротевшей записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём после закрытия записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
|
||||
`target_missing`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём при прочих терминальных состояниях
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
|
||||
источник ради незакрытого намерения)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
|
||||
свежая попытка; старая терминальная запись хешем не владеет)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Ручное закрытие загрузки (стоп-кран)
|
||||
|
||||
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
|
||||
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
|
||||
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
|
||||
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
|
||||
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
|
||||
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
|
||||
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
|
||||
`cancelled` команда SHALL быть идемпотентным no-op.
|
||||
|
||||
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
|
||||
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
|
||||
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
|
||||
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
|
||||
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
|
||||
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
|
||||
|
||||
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
|
||||
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
|
||||
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
|
||||
существующее терминальное `cancelled` (сверка его не переоценивает). Из
|
||||
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
|
||||
передумает.
|
||||
|
||||
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||||
страницы загрузки), обособленно от штатных действий.
|
||||
|
||||
#### Scenario: Закрытие записи без цели не трогает раздачу
|
||||
|
||||
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
|
||||
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** раздача с файлами в qBittorrent не удаляется
|
||||
- **AND** запись пропадает из активного списка
|
||||
|
||||
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
|
||||
|
||||
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** библиотечные хардлинки не удаляются
|
||||
- **AND** раздача в qBittorrent не снимается
|
||||
|
||||
#### Scenario: Закрытие зависшей загрузки
|
||||
|
||||
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled`
|
||||
- **AND** источник в qBittorrent не трогается
|
||||
|
||||
#### Scenario: «Закрыть» недоступна для deleted
|
||||
|
||||
- **GIVEN** загрузка в `deleted`
|
||||
- **WHEN** пользователь пытается вызвать «Закрыть»
|
||||
- **THEN** команда недоступна, состояние остаётся `deleted`
|
||||
|
||||
#### Scenario: Закрытую запись можно привязать заново
|
||||
|
||||
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
|
||||
- **WHEN** пользователь даёт команду «Привязать заново»
|
||||
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
|
||||
из `cancelled`)
|
||||
@@ -0,0 +1,58 @@
|
||||
## 1. Дедуп: reingest-blocking states (предотвращение дубля)
|
||||
|
||||
- [x] 1.1 В `internal/store/download.go` ввести множество reingest-blocking
|
||||
состояний (active ∪ `{target_missing, orphaned}`) и **отдельный** метод
|
||||
поиска по любому из хешей (напр. `FindReingestBlockingByInfohash`) с
|
||||
приоритетом активной записи над desync-записью. НЕ расширять общий
|
||||
`findActiveByInfohash` — на нём стоят `ActivateIfNoOtherActive`/
|
||||
`AddInfohashes`, где «активная» ОБЯЗАНА значить строго не-терминальную
|
||||
(иначе ломается инвариант «≤1 активной»). Desync-проверка — устойчивый
|
||||
пред-рид ДО create, активный гард (`CreateDownloadIfNoActive`) остаётся про
|
||||
строго активные.
|
||||
- [x] 1.2 В `internal/ingest/ingest.go` (`Ingest`/`attached`) при найденной
|
||||
desync-записи (`target_missing`/`orphaned`) вернуть её как `Deduplicated`
|
||||
«спящей»: без перевода в активное, без обращения к qBittorrent; добавить
|
||||
в результат приёма признак «требует relink/закрытия».
|
||||
- [x] 1.3 Убедиться, что донесение недостающих хешей и атомарный гард создания
|
||||
(`CreateDownloadIfNoActive`) не крадут хеш у другой активной загрузки и
|
||||
что гонка двух приёмов на свежую desync-запись не рождает близнеца.
|
||||
- [x] 1.4 Обновить ответы транспортов при attach на desync-запись: бот
|
||||
(`internal/tgbot`) и HTTP/веб — текст «существует как #id без цели —
|
||||
привяжите заново или закройте».
|
||||
|
||||
## 2. Команда «Закрыть» (dismiss) — универсальный стоп-кран
|
||||
|
||||
- [x] 2.1 В `internal/store/download.go` добавить рёбра `allowedTransitions` в
|
||||
`cancelled` из терминальных `done`/`failed`/`reverted`/`target_missing`/
|
||||
`orphaned` (у нетерминальных цель уже есть; `deleted` исключён). Обновить
|
||||
граф-тесты (`internal/store/transition_test.go`): (а) РАСЩЕПИТЬ
|
||||
`TestCancelDeferReachableFromEveryNonTerminal` — `cancelled` теперь цель из
|
||||
любого состояния кроме `deleted` (в т.ч. терминальных), `deferred` —
|
||||
по-прежнему только из не-терминальных (не расширять общий цикл наивно, иначе
|
||||
ложно потребует `deferred` из терминалов или оставит дырявый гард);
|
||||
(б) в `TestSetStateAllowsDeclaredEdges` добавить новые терминал→`cancelled`
|
||||
рёбра (переход обычным `SetDownloadState`, без `ActivateIfNoOtherActive`).
|
||||
- [x] 2.2 В `internal/worker/review.go` реализовать **отдельный** обработчик
|
||||
«Закрыть» (не ветка `Cancel`, который отклоняет терминальные): перевод
|
||||
`<любое, кроме deleted> → cancelled` с `error_code = "user_dismiss"` и
|
||||
причиной в `error_msg`/логе. Только setState: qBittorrent не вызывать,
|
||||
хардлинки не трогать (в т.ч. из `done`/`orphaned`), source-preflight не
|
||||
выполнять; из `deleted` — отказ; из `cancelled` — короткозамкнуть без
|
||||
`setState` (иначе идемпотентный no-op перезапишет `error_code`, подменив
|
||||
причину прежнего `Отклонить`).
|
||||
- [x] 2.3 Веб-UI: отдельная danger zone внизу страницы загрузки с кнопкой
|
||||
«Закрыть» (доступна из любого состояния, кроме `deleted`; htmx, деградация
|
||||
без JS, ошибка на htmx-пути = 200 + фрагмент). Рассмотреть подтверждение
|
||||
для `done`/терминальных.
|
||||
- [x] 2.4 Telegram (`internal/tgbot`): добавить действие «Закрыть» для загрузок.
|
||||
|
||||
## 3. Спеки, документация, проверка
|
||||
|
||||
- [x] 3.1 Обновить описание статусов/переходов в `docs/specs/database.md`
|
||||
(рёбра `<терминальные> → cancelled`, `error_code = "user_dismiss"`).
|
||||
- [x] 3.2 `task test` и `task lint` зелёные; добавить тесты: дедуп-attach на
|
||||
`target_missing`/`orphaned`, повторный приём после `cancelled`/`done`
|
||||
заводит новую, команда «Закрыть» переводит в `cancelled` из разных
|
||||
состояний и НЕ трогает qBittorrent/хардлинки (в т.ч. из `done`), отказ из
|
||||
`deleted`.
|
||||
- [x] 3.3 `openspec validate dedup-target-missing-and-dismiss --strict`.
|
||||
@@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name
|
||||
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
|
||||
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
|
||||
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
|
||||
выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
|
||||
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
|
||||
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
|
||||
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
|
||||
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
|
||||
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
состояние»).
|
||||
|
||||
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||
@@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
|
||||
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
|
||||
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
|
||||
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
|
||||
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
|
||||
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
|
||||
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
|
||||
новую загрузку.
|
||||
|
||||
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
|
||||
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
|
||||
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
|
||||
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
|
||||
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
|
||||
перепривязки либо закрытия.
|
||||
|
||||
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
|
||||
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
|
||||
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
|
||||
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
|
||||
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
|
||||
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
|
||||
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
|
||||
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
|
||||
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
|
||||
NOT — активность выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
@@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём при записи без цели
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
|
||||
удалена)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
|
||||
qBittorrent обращения нет)
|
||||
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
|
||||
заново или закрыть
|
||||
|
||||
#### Scenario: Повторный приём при осиротевшей записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём после закрытия записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
|
||||
`target_missing`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём при прочих терминальных состояниях
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
|
||||
источник ради незакрытого намерения)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
|
||||
свежая попытка; старая терминальная запись хешем не владеет)
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
|
||||
@@ -534,3 +534,68 @@ SHALL задевать раскладку в полёте.
|
||||
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||
- **AND** повторный delete идемпотентно дожимает удаление
|
||||
|
||||
### Requirement: Ручное закрытие загрузки (стоп-кран)
|
||||
|
||||
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
|
||||
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
|
||||
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
|
||||
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
|
||||
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
|
||||
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
|
||||
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
|
||||
`cancelled` команда SHALL быть идемпотентным no-op.
|
||||
|
||||
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
|
||||
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
|
||||
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
|
||||
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
|
||||
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
|
||||
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
|
||||
|
||||
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
|
||||
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
|
||||
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
|
||||
существующее терминальное `cancelled` (сверка его не переоценивает). Из
|
||||
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
|
||||
передумает.
|
||||
|
||||
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||||
страницы загрузки), обособленно от штатных действий.
|
||||
|
||||
#### Scenario: Закрытие записи без цели не трогает раздачу
|
||||
|
||||
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
|
||||
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** раздача с файлами в qBittorrent не удаляется
|
||||
- **AND** запись пропадает из активного списка
|
||||
|
||||
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
|
||||
|
||||
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||||
- **AND** библиотечные хардлинки не удаляются
|
||||
- **AND** раздача в qBittorrent не снимается
|
||||
|
||||
#### Scenario: Закрытие зависшей загрузки
|
||||
|
||||
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
|
||||
- **WHEN** пользователь даёт команду «Закрыть»
|
||||
- **THEN** запись переходит в `cancelled`
|
||||
- **AND** источник в qBittorrent не трогается
|
||||
|
||||
#### Scenario: «Закрыть» недоступна для deleted
|
||||
|
||||
- **GIVEN** загрузка в `deleted`
|
||||
- **WHEN** пользователь пытается вызвать «Закрыть»
|
||||
- **THEN** команда недоступна, состояние остаётся `deleted`
|
||||
|
||||
#### Scenario: Закрытую запись можно привязать заново
|
||||
|
||||
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
|
||||
- **WHEN** пользователь даёт команду «Привязать заново»
|
||||
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
|
||||
из `cancelled`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user