Compare commits
3
Commits
0c9421f4c1
...
e8ce828296
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ce828296
|
||
|
|
1369a9cabe
|
||
|
|
b8657120fe
|
@@ -36,11 +36,12 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||||
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
|
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
|
||||||
- [processCatched: promote-without-add если торрент уже в qBittorrent (F2)](review-f2-promote-without-add.md) — торрент уже в qBittorrent → processCatched зациклен на Add вместо promote _(ревью 2026-07-08)_
|
|
||||||
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
|
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
|
||||||
|
|
||||||
## Низкий
|
## Низкий
|
||||||
|
|
||||||
|
- [Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)](knopka-obnovit-imya-polnyj-format.md) — Перелив имени даёт усечённое «Title (Year)» вместо полного формата add-шага (режиссёр+сезон); нужный `render` уже есть
|
||||||
|
- [Скрывать отменённые (cancelled) в общем списке наравне с удалёнными](skryvat-otmenennye-v-spiske.md) — cancelled шумит в ленте; тумблер «показать удалённые» должен раскрывать и cancelled, и deleted
|
||||||
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
|
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
|
||||||
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
|
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
|
||||||
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)
|
||||||
|
|
||||||
|
**Приоритет:** низкий · **Теги:** web-ui, naming, review
|
||||||
|
|
||||||
|
Кнопка «Обновить имя» (перелив распознанного названия в `display_name` и ярлык
|
||||||
|
раздачи) сейчас генерирует усечённый формат `Title (Year)` через
|
||||||
|
`naming.FormatTitleYear(plan.Title, plan.Year)`
|
||||||
|
(`internal/worker/review.go:1110`). Ожидается тот же формат, что и «полное»
|
||||||
|
отображаемое имя add-шага: **`Название (режиссёр, год)`**, а для сериала — плюс
|
||||||
|
**`. Сезон N`**. Всё опционально, кроме названия.
|
||||||
|
|
||||||
|
Нужный формат уже реализован приватной `render()` в
|
||||||
|
`internal/naming/naming.go:104-140` (`movie: "Title (Director, Year)"`, `series`
|
||||||
|
+ `". Сезон N"`), но `RefreshDisplayName` его не использует — комментарий у
|
||||||
|
`FormatTitleYear` (`naming.go:88-95`) прямо отмечает: «у перелива режиссёра и
|
||||||
|
сезона нет». Задача — устранить это расхождение: перелив должен давать тот же
|
||||||
|
ярлык, что и add-шаг.
|
||||||
|
|
||||||
|
Шаги:
|
||||||
|
- Экспонировать `render`-логику (публичная обёртка в `internal/naming`) или
|
||||||
|
собрать `extracted` из плана и позвать её из `RefreshDisplayName`.
|
||||||
|
- **Сезон** — из плана (`recognize.PlanFile.Season`, `recognize.go:77`): для
|
||||||
|
сериала взять сезон (единый — подставить, разные — решить: опустить или взять
|
||||||
|
минимальный; свериться с логикой сводки сезонов на странице).
|
||||||
|
- **Режиссёр** — открытый вопрос: в `recognize.Plan` его сейчас нет. Найти
|
||||||
|
источник (матч метабазы TMDB/TVDB несёт режиссёра? recognition?) или, если
|
||||||
|
недоступен, опустить (формат допускает отсутствие). Уточнить, откуда add-шаг
|
||||||
|
берёт режиссёра, и переиспользовать тот же источник.
|
||||||
|
- Все части, кроме названия, опциональны — пустой режиссёр/год/сезон просто
|
||||||
|
выпадают из скобок/хвоста (как уже делает `render`).
|
||||||
|
|
||||||
|
Проверить: единый формат ярлыка раздачи и `display_name`; санитайзинг и лимит
|
||||||
|
длины (`maxNameLen`) сохраняются; поведение на `done`/`orphaned` (кнопка гейтится
|
||||||
|
наличием распознавания, не состоянием — `internal/httpapi/download.go:142`).
|
||||||
|
|
||||||
|
Связано: `openspec/specs/recognition`, `openspec/specs/review`, пакеты
|
||||||
|
`internal/naming`, `internal/worker`, `internal/recognize`.
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# processCatched: promote-without-add если торрент уже в qBittorrent (F2)
|
|
||||||
|
|
||||||
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08, lifecycle
|
|
||||||
|
|
||||||
Ревью Fable 2026-07-08 (приём). worker.go:361-391, sourceAddParts :407-432, qbt.go:243-246.
|
|
||||||
|
|
||||||
Сценарий: торрент уже в qBittorrent БЕЗ нашей категории/тега (юзер добавил вручную раньше → discover не усыновляет). Юзер грузит тот же .torrent в jellybit → catched → qbt.Add файлом; для file-add дубль → «Fails.» → Add ошибка → «will retry» каждый тик, вечно, до catch_timeout → failed/qbit_add, который reconcileRecovery НЕ воскрешает (worker.go:124-132). Торрент жив всё это время; юзер видит failed. Retry уже решает это alive-проверкой (worker.go:692-698 «повторный Add вреден»), а processCatched — нет, хотя live-снимок byHash того же тика доступен. Также лечит сценарий B (Add успех, PromoteCatched падает на транзиентной ошибке → снова Add дубля).
|
|
||||||
|
|
||||||
Замечание: поведение qBit на дубль file-add версионно-зависимо («Fails.» vs «Ok.») — проверить на целевой версии.
|
|
||||||
|
|
||||||
Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry).
|
|
||||||
|
|
||||||
Смежное (ревью 2026-07-08, кластер A): апгрейд F6 (`UpgradeCatchedMagnetToTorrent`)
|
|
||||||
оставил узкое окно — `processCatched` читает снимок `source_type` вне `w.mu`, поэтому
|
|
||||||
при точном оверлапе тика воркера с апгрейдом воркер добавит magnet из устаревшего
|
|
||||||
снимка, хотя БД уже `torrent`. Тот же фикс закрывает и это: перечитать источник под
|
|
||||||
`w.mu` (или проверить присутствие хешей в qBit) перед Add.
|
|
||||||
|
|
||||||
Вердикт: change (малая спека-дельта download-tracking + код).
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Скрывать отменённые (cancelled) в общем списке наравне с удалёнными
|
||||||
|
|
||||||
|
**Приоритет:** низкий · **Теги:** web-ui
|
||||||
|
|
||||||
|
Отменённые загрузки (`cancelled`) должны по умолчанию скрываться в общем списке
|
||||||
|
так же, как удалённые (`deleted`), а тумблер «Показать всё, включая удалённые»
|
||||||
|
(`web/templates/index.html:52`) должен показывать **и те, и те**. Сейчас под
|
||||||
|
группой `all` скрыт только `deleted`, а `cancelled` всегда виден и шумит в ленте
|
||||||
|
— тем более что новый стоп-кран «Закрыть» будет плодить `cancelled`-записи.
|
||||||
|
|
||||||
|
Где править:
|
||||||
|
- `internal/store/list.go:62-66` — ветка `else if !f.IncludeDeleted` под группой
|
||||||
|
`all` скрывает только `StateDeleted` (`state <> ?`). Расширить до
|
||||||
|
`state NOT IN (deleted, cancelled)`.
|
||||||
|
- Осмыслить семантику флага `ListFilter.IncludeDeleted` (`list.go:45`): теперь он
|
||||||
|
«показывать скрытые терминальные» (deleted **и** cancelled). Либо переименовать
|
||||||
|
(`IncludeHidden`/`IncludeTerminal`), либо оставить имя и уточнить комментарий.
|
||||||
|
- Метка тумблера (`index.html:52`) и текст — при желании подправить («…включая
|
||||||
|
отменённые и удалённые»); `ShowAll`/`all=1` уже управляет показом.
|
||||||
|
- Комментарии-инварианты про «под all видны» в `internal/store/list.go:22-26` и
|
||||||
|
`statesInGroup` (cancelled/reverted/deleted не имеют чипа) — привести в
|
||||||
|
соответствие.
|
||||||
|
|
||||||
|
Замечания:
|
||||||
|
- `cancelled` своей группы-чипа не имеет (`statesInGroup`, `list.go:29-40`) — под
|
||||||
|
группами `review/active/done/problem` он и так не появляется, правка нужна
|
||||||
|
только в ветке `all`.
|
||||||
|
- `reverted` пользователь не упоминал — по умолчанию оставляем видимым; при
|
||||||
|
желании обсудить симметрию отдельно.
|
||||||
|
- Обновить тест `TestListDownloadsPageFilterAndDeleted`
|
||||||
|
(`internal/store/list_test.go`) — добавить `cancelled` в скрытые по умолчанию и
|
||||||
|
видимые под `IncludeDeleted`.
|
||||||
|
|
||||||
|
Связано: `openspec/specs/web-ui`, пакеты `internal/store`, `internal/httpapi`,
|
||||||
|
конвенция [web-ui](../conventions/web-ui.md).
|
||||||
+13
-2
@@ -65,8 +65,10 @@ stateDiagram-v2
|
|||||||
deleted --> [*]
|
deleted --> [*]
|
||||||
|
|
||||||
note right of cancelled
|
note right of cancelled
|
||||||
«Отклонить» доступно из любого
|
В cancelled ведут: «Отклонить»
|
||||||
нетерминального состояния
|
(из нетерминальных) и «Закрыть»
|
||||||
|
(стоп-кран — из любого состояния,
|
||||||
|
кроме deleted; только статус)
|
||||||
end note
|
end note
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -134,6 +136,15 @@ stateDiagram-v2
|
|||||||
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
|
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
|
||||||
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
|
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
|
||||||
|
|
||||||
|
**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме
|
||||||
|
`deleted`: переводит запись в терминальный `cancelled` (`error_code =
|
||||||
|
"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки
|
||||||
|
`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в
|
||||||
|
отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр.
|
||||||
|
дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка.
|
||||||
|
Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран
|
||||||
|
показывается там, где иного выхода нет (терминальные, кроме `deleted`).
|
||||||
|
|
||||||
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
|
||||||
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
|
||||||
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type actionReviewer struct {
|
|||||||
stubReviewer
|
stubReviewer
|
||||||
undoErr error
|
undoErr error
|
||||||
deleteErr error
|
deleteErr error
|
||||||
|
dismissErr error
|
||||||
relinkErr error
|
relinkErr error
|
||||||
rerecognizeErr error
|
rerecognizeErr error
|
||||||
refineErr error
|
refineErr error
|
||||||
@@ -34,6 +35,7 @@ type actionReviewer struct {
|
|||||||
|
|
||||||
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
|
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
|
||||||
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
|
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
|
||||||
|
func (a actionReviewer) Dismiss(context.Context, string) error { return a.dismissErr }
|
||||||
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
|
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
|
||||||
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
||||||
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ type downloadDetailView struct {
|
|||||||
Relinkable bool
|
Relinkable bool
|
||||||
Retriable bool
|
Retriable bool
|
||||||
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
|
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
|
||||||
|
// Dismissable — доступен стоп-кран «Закрыть» (перевод в cancelled без
|
||||||
|
// действий над файлами/раздачей). Показываем в danger-зоне для терминальных,
|
||||||
|
// кроме deleted (строго терминален) и cancelled (уже закрыта, no-op); у
|
||||||
|
// не-терминальных ту же роль играет обычная «Отменить» — не дублируем.
|
||||||
|
Dismissable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
||||||
@@ -119,6 +124,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||||
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
|
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
|
||||||
d.State == store.StateTargetMissing,
|
d.State == store.StateTargetMissing,
|
||||||
|
Dismissable: d.State.IsTerminal() &&
|
||||||
|
d.State != store.StateDeleted && d.State != store.StateCancelled,
|
||||||
}
|
}
|
||||||
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
|
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
|
||||||
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||||
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
||||||
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
|
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
|
||||||
|
r.Post("/ui/downloads/{id}/dismiss", s.handleDismiss)
|
||||||
|
|
||||||
// REST API.
|
// REST API.
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
@@ -432,6 +433,14 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if res.Deduplicated {
|
||||||
|
// Приём привязался к существующей записи (активной или «спящей» desync —
|
||||||
|
// target_missing/orphaned): ведём пользователя на её страницу, а не на
|
||||||
|
// список. Так видно, что нового не завели, и доступны действия записи
|
||||||
|
// (привязать заново / danger-зона «Закрыть»).
|
||||||
|
http.Redirect(w, r, "/download/"+res.DownloadID, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ type fakeReviewer struct {
|
|||||||
deferred []string
|
deferred []string
|
||||||
undone []string
|
undone []string
|
||||||
deleted []string
|
deleted []string
|
||||||
|
dismissed []string
|
||||||
relinked []string
|
relinked []string
|
||||||
rerecognized []string
|
rerecognized []string
|
||||||
cleared []string
|
cleared []string
|
||||||
@@ -532,6 +533,10 @@ func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
|||||||
f.deleted = append(f.deleted, id)
|
f.deleted = append(f.deleted, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
|
||||||
|
f.dismissed = append(f.dismissed, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||||
f.relinked = append(f.relinked, id)
|
f.relinked = append(f.relinked, id)
|
||||||
return nil
|
return nil
|
||||||
@@ -614,6 +619,28 @@ func noRedirectClient() *http.Client {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу
|
||||||
|
// существующей записи, а не на список — пользователь видит, что нового не завели.
|
||||||
|
func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) {
|
||||||
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads",
|
||||||
|
strings.NewReader("source=magnet:?xt=urn:btih:abc"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := noRedirectClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
|
||||||
|
t.Errorf("Location = %q, want /download/%s", loc, tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReviewRenders(t *testing.T) {
|
func TestReviewRenders(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
@@ -765,6 +792,45 @@ func TestRefreshNameHTMXSwapsMain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния
|
||||||
|
// (target_missing) и постит на /dismiss.
|
||||||
|
func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) {
|
||||||
|
rd := seriesReviewData()
|
||||||
|
rd.Download.State = store.StateTargetMissing
|
||||||
|
rv := &fakeReviewer{data: rd}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") {
|
||||||
|
t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss.
|
||||||
|
func TestUIDismiss(t *testing.T) {
|
||||||
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "application/x-www-form-urlencoded", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if len(rv.dismissed) != 1 || rv.dismissed[0] != tid {
|
||||||
|
t.Errorf("Dismiss вызван неверно: %v", rv.dismissed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
|
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
|
||||||
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
|
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
|
||||||
// done (есть план) она всё равно доступна.
|
// done (есть план) она всё равно доступна.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func (stubReviewer) IgnoreFile(context.Context, string, string) error
|
|||||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Delete(context.Context, string) error { return nil }
|
func (stubReviewer) Delete(context.Context, string) error { return nil }
|
||||||
|
func (stubReviewer) Dismiss(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
func (stubReviewer) Relink(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Reviewer interface {
|
|||||||
Defer(ctx context.Context, id string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Undo(ctx context.Context, id string) error
|
Undo(ctx context.Context, id string) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
Dismiss(ctx context.Context, id string) error
|
||||||
Relink(ctx context.Context, id string) error
|
Relink(ctx context.Context, id string) error
|
||||||
Rerecognize(ctx context.Context, id string) error
|
Rerecognize(ctx context.Context, id string) error
|
||||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||||
@@ -370,6 +371,17 @@ func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
|
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDismiss — универсальный стоп-кран: перевод задачи в cancelled только
|
||||||
|
// сменой статуса (файлы/раздачу не трогает), из danger-секции с подтверждением.
|
||||||
|
func (s *server) handleDismiss(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
redirectErr(w, r, "некорректный id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.surfaceAction(w, r, id, s.deps.Reviewer.Dismiss(r.Context(), id))
|
||||||
|
}
|
||||||
|
|
||||||
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||||
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
||||||
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+22
-15
@@ -23,9 +23,11 @@ const capIngest = "ingest"
|
|||||||
|
|
||||||
// Store — нужная ingest часть хранилища.
|
// Store — нужная ingest часть хранилища.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
|
// FindReingestBlockingByInfohash — быстрый читающий дедуп-чек: активная задача
|
||||||
// внутри CreateDownloadIfNoActive.
|
// ЛИБО удерживающая источник desync-запись (target_missing/orphaned). Активный
|
||||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
// инвариант «≤1 активной» авторитетно держит CreateDownloadIfNoActive; desync —
|
||||||
|
// устойчивый пред-рид, коротко замыкающий приём на возврат существующей записи.
|
||||||
|
FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||||
@@ -87,15 +89,18 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
||||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
// активная задача ЛИБО удерживающая источник desync-запись
|
||||||
// гибридный несёт и v1, и v2.
|
// (target_missing/orphaned) блокируют повторный приём. Для активной
|
||||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
|
||||||
|
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
|
||||||
|
// размыть инвариант «≤1 активной»).
|
||||||
|
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
|
||||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||||
log.Error("ingest failed", "stage", "lookup-active", "error", err)
|
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
|
||||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err)
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
|
||||||
return s.attached(ctx, src, existing), nil
|
return s.attached(ctx, src, existing), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +209,13 @@ func mergeContext(userText, synth string) string {
|
|||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// attached — итог дедупа на быстром чеке: присоединились к уже активной
|
// attached — итог дедупа на быстром чеке: присоединились к блокирующей записи
|
||||||
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
// (активной ЛИБО удерживающей источник desync — target_missing/orphaned) и
|
||||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
// доносим ей недостающие хеши источника (гибридный magnet мог принести хеш,
|
||||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
// которого задача ещё не знает; guarded-путь через CreateDownloadIfNoActive сюда
|
||||||
// хеша с другой активной задачей логируется, приём не валится.
|
// не доходит). Для desync-записи состояние не меняем (возвращаем «спящей» — relink
|
||||||
|
// или закрытие делает пользователь). Донос — best-effort: конфликт хеша с другой
|
||||||
|
// активной задачей логируется, приём не валится.
|
||||||
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||||
if len(src.infohashes) > len(existing.Infohashes) {
|
if len(src.infohashes) > len(existing.Infohashes) {
|
||||||
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type fakeStore struct {
|
|||||||
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
|
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||||
return f.active, nil
|
return f.active, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +159,31 @@ func TestIngestIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Повторный приём привязывается к удерживающей источник desync-записи
|
||||||
|
// (target_missing/orphaned) вместо создания близнеца: возвращается существующая
|
||||||
|
// «спящей» (её состояние не меняется, к qBittorrent не ходим), Deduplicated=true.
|
||||||
|
func TestIngestAttachesToDesyncRecord(t *testing.T) {
|
||||||
|
for _, s := range []store.State{store.StateTargetMissing, store.StateOrphaned} {
|
||||||
|
t.Run(string(s), func(t *testing.T) {
|
||||||
|
existing := &store.Download{ID: "01hzzzexisting000000000000", State: s}
|
||||||
|
fs := &fakeStore{active: existing}
|
||||||
|
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Deduplicated || res.DownloadID != existing.ID {
|
||||||
|
t.Errorf("ожидалось присоединение к desync-записи: %+v", res)
|
||||||
|
}
|
||||||
|
if res.State != s {
|
||||||
|
t.Errorf("состояние существующей записи должно вернуться как есть (%s), got %s", s, res.State)
|
||||||
|
}
|
||||||
|
if len(fs.created) != 0 {
|
||||||
|
t.Error("не должно создаваться новой задачи (близнеца)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||||
// гибридного magnet (иначе последующий приём по второму хешу создал бы
|
// гибридного magnet (иначе последующий приём по второму хешу создал бы
|
||||||
// вторую активную задачу).
|
// вторую активную задачу).
|
||||||
|
|||||||
@@ -78,9 +78,14 @@ func (s State) IsTerminal() bool {
|
|||||||
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
||||||
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
||||||
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
||||||
// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния
|
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
|
||||||
// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не
|
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
|
||||||
// ручной аккуратностью.
|
// аккуратностью.
|
||||||
|
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
||||||
|
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
||||||
|
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
||||||
|
// смена статуса, файлы/раздачу не трогает (см. state-reconciliation «Ручное
|
||||||
|
// закрытие загрузки»). deleted строго терминален и цель cancelled не получает.
|
||||||
//
|
//
|
||||||
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
||||||
// setState отклонит переход (0 строк UPDATE → ошибка).
|
// setState отклонит переход (0 строк UPDATE → ошибка).
|
||||||
@@ -91,14 +96,14 @@ var allowedTransitions = map[State][]State{
|
|||||||
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
||||||
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
|
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
|
||||||
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
|
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
|
||||||
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted},
|
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, StateCancelled},
|
||||||
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
|
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
|
||||||
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
|
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
|
||||||
StateFailed: {StateDownloading, StateCompleted},
|
StateFailed: {StateDownloading, StateCompleted, StateCancelled},
|
||||||
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted},
|
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted, StateCancelled},
|
||||||
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
|
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
|
||||||
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted},
|
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted, StateCancelled},
|
||||||
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted},
|
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted, StateCancelled},
|
||||||
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
|
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +621,68 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Do
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reingestHoldingStates — desync-состояния, которые удерживают источник ради
|
||||||
|
// незакрытого намерения и потому БЛОКИРУЮТ повторный приём наравне с активными:
|
||||||
|
// target_missing (источник жив, ждёт relink) и orphaned (источник пропал, запись
|
||||||
|
// держит претензию на последнюю копию). Прочие терминальные (done/cancelled/
|
||||||
|
// failed/reverted/deleted) повторный приём НЕ блокируют — это осознанная свежая
|
||||||
|
// попытка. «Блокирующие» = активные (не-терминальные) ∪ reingestHoldingStates.
|
||||||
|
var reingestHoldingStates = []State{StateTargetMissing, StateOrphaned}
|
||||||
|
|
||||||
|
// FindReingestBlockingByInfohash возвращает задачу, блокирующую повторный приём
|
||||||
|
// любого из hashes: активную (строго не-терминальную) ЛИБО удерживающую источник
|
||||||
|
// desync-запись (target_missing/orphaned), приоритет — активной. Либо (nil, nil).
|
||||||
|
// Читающая основа расширенного дедупа приёма (пред-рид ДО создания); инвариант
|
||||||
|
// «≤1 активной на infohash» держит отдельный active-гард CreateDownloadIfNoActive,
|
||||||
|
// в который desync-состояния НЕ заводятся.
|
||||||
|
func (s *Store) FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
|
||||||
|
norm := normalizeHashes(hashes)
|
||||||
|
// Активная имеет приоритет: если по хешу есть и активная, и desync-запись
|
||||||
|
// (инвариант это допускает), присоединяемся к активной.
|
||||||
|
d, err := findActiveByInfohash(ctx, s.DB, norm, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d == nil {
|
||||||
|
d, err = findByInfohashInStates(ctx, s.DB, norm, reingestHoldingStates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d != nil {
|
||||||
|
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByInfohashInStates — выборка «задача по любому из хешей в одном из states»
|
||||||
|
// (позитивный фильтр `state IN (...)`, в отличие от findActiveByInfohash с
|
||||||
|
// `NOT IN terminalStates`). hashes уже нормализованы; хеши найденной загрузки НЕ
|
||||||
|
// подгружаются. Пустые hashes/states → (nil, nil).
|
||||||
|
func findByInfohashInStates(ctx context.Context, q sqlx.QueryerContext, hashes []string, states []State) (*Download, error) {
|
||||||
|
if len(hashes) == 0 || len(states) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var args []any
|
||||||
|
hashPh := placeholders(&args, hashes)
|
||||||
|
statePh := placeholders(&args, states)
|
||||||
|
query := `SELECT download.* FROM download
|
||||||
|
JOIN download_infohash dh ON dh.download_id = download.id
|
||||||
|
WHERE dh.infohash IN (` + hashPh + `) AND download.state IN (` + statePh + `)
|
||||||
|
ORDER BY download.id DESC LIMIT 1`
|
||||||
|
var d Download
|
||||||
|
err := sqlx.GetContext(ctx, q, &d, query, args...)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find by infohash in states: %w", err)
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
|
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
|
||||||
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
|
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
|
||||||
// excludeID исключает саму проверяемую задачу (она может быть активной,
|
// excludeID исключает саму проверяемую задачу (она может быть активной,
|
||||||
|
|||||||
@@ -260,6 +260,71 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дедуп повторного приёма блокируют не только активные, но и удерживающие
|
||||||
|
// источник desync-записи (target_missing/orphaned): по ним приём привязывается к
|
||||||
|
// существующей, а не плодит близнеца. Прочие терминальные (done/cancelled/failed/
|
||||||
|
// reverted/deleted) повторный приём НЕ блокируют — осознанная свежая попытка.
|
||||||
|
func TestFindReingestBlockingByInfohash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
blocking := []State{
|
||||||
|
StateCatched, StateDownloading, StateReview, // активные (примеры)
|
||||||
|
StateTargetMissing, StateOrphaned, // desync, удерживающие источник
|
||||||
|
}
|
||||||
|
for _, s := range blocking {
|
||||||
|
t.Run("blocking/"+string(s), func(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ih := hashN(1)
|
||||||
|
id := mustCreate(t, st, ih)
|
||||||
|
forceState(t, st, id, s)
|
||||||
|
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d == nil || d.ID != id {
|
||||||
|
t.Fatalf("%s должна блокировать приём, получили %v", s, d)
|
||||||
|
}
|
||||||
|
if len(d.Infohashes) != 1 {
|
||||||
|
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nonBlocking := []State{
|
||||||
|
StateDone, StateCancelled, StateFailed, StateReverted, StateDeleted,
|
||||||
|
}
|
||||||
|
for _, s := range nonBlocking {
|
||||||
|
t.Run("non-blocking/"+string(s), func(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ih := hashN(2)
|
||||||
|
id := mustCreate(t, st, ih)
|
||||||
|
forceState(t, st, id, s)
|
||||||
|
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||||
|
if err != nil || d != nil {
|
||||||
|
t.Fatalf("%s блокировать приём не должна, получили (%v,%v)", s, d, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("priority-active-over-desync", func(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ih := hashN(3)
|
||||||
|
// Старая запись ушла в target_missing (terminal освобождает хеш), затем по
|
||||||
|
// тому же хешу завелась новая активная — инвариант «≤1 активной» это
|
||||||
|
// допускает. Дедуп обязан присоединиться к активной, а не к desync.
|
||||||
|
oldID := mustCreate(t, st, ih)
|
||||||
|
forceState(t, st, oldID, StateTargetMissing)
|
||||||
|
newID := mustCreate(t, st, ih)
|
||||||
|
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d == nil || d.ID != newID {
|
||||||
|
t.Fatalf("ожидалась активная %s (приоритет над desync %s), получили %v", newID, oldID, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
||||||
// новой задачей (повторная закачка спустя время) — активность выводится
|
// новой задачей (повторная закачка спустя время) — активность выводится
|
||||||
// только из state.
|
// только из state.
|
||||||
|
|||||||
@@ -74,19 +74,12 @@ func TestTransitionGraphWellFormed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель
|
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
|
||||||
// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred —
|
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
|
||||||
// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после
|
// состояние» (напр. linking после краха процесса).
|
||||||
// краха процесса).
|
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
|
||||||
func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
|
|
||||||
for _, s := range allStates {
|
for _, s := range allStates {
|
||||||
if s.IsTerminal() {
|
if s.IsTerminal() || s == StateDeferred {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !slices.Contains(transitionSources[StateCancelled], s) {
|
|
||||||
t.Errorf("%s → cancelled не легально (Cancel допускает любое не-терминальное)", s)
|
|
||||||
}
|
|
||||||
if s == StateDeferred {
|
|
||||||
continue // deferred → deferred покрыт самопереходом
|
continue // deferred → deferred покрыт самопереходом
|
||||||
}
|
}
|
||||||
if !slices.Contains(transitionSources[StateDeferred], s) {
|
if !slices.Contains(transitionSources[StateDeferred], s) {
|
||||||
@@ -95,6 +88,22 @@ func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
|
||||||
|
// ЛЮБОГО состояния, кроме deleted (строго терминален) и самого cancelled
|
||||||
|
// (самопереход). Шире инварианта Defer: покрывает и терминальные
|
||||||
|
// done/failed/reverted/target_missing/orphaned. НЕ объединять с проверкой
|
||||||
|
// deferred — у них разные множества источников.
|
||||||
|
func TestCancelledReachableFromEveryStateButDeleted(t *testing.T) {
|
||||||
|
for _, s := range allStates {
|
||||||
|
if s == StateDeleted || s == StateCancelled {
|
||||||
|
continue // deleted строго терминален; cancelled → cancelled — самопереход
|
||||||
|
}
|
||||||
|
if !slices.Contains(transitionSources[StateCancelled], s) {
|
||||||
|
t.Errorf("%s → cancelled не легально (Dismiss/Cancel допускают любое состояние, кроме deleted)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Объявленные не-revive рёбра проходят через SetDownloadState.
|
// Объявленные не-revive рёбра проходят через SetDownloadState.
|
||||||
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
|
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
|
||||||
edges := []struct{ from, to State }{
|
edges := []struct{ from, to State }{
|
||||||
@@ -110,6 +119,14 @@ func TestSetStateAllowsDeclaredEdges(t *testing.T) {
|
|||||||
{StateDone, StateReverted},
|
{StateDone, StateReverted},
|
||||||
{StateStuck, StateCancelled},
|
{StateStuck, StateCancelled},
|
||||||
{StateReview, StateDeferred},
|
{StateReview, StateDeferred},
|
||||||
|
// Стоп-кран Dismiss: терминал → cancelled идёт обычным SetDownloadState
|
||||||
|
// (цель cancelled терминальна → гард терминальности не мешает, revive не
|
||||||
|
// нужен).
|
||||||
|
{StateDone, StateCancelled},
|
||||||
|
{StateFailed, StateCancelled},
|
||||||
|
{StateReverted, StateCancelled},
|
||||||
|
{StateTargetMissing, StateCancelled},
|
||||||
|
{StateOrphaned, StateCancelled},
|
||||||
}
|
}
|
||||||
for i, e := range edges {
|
for i, e := range edges {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
|
|||||||
+31
-3
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ type Reviewer interface {
|
|||||||
Cancel(ctx context.Context, id string) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id string) error
|
Retry(ctx context.Context, id string) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
Dismiss(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — параметры бота.
|
// Config — параметры бота.
|
||||||
@@ -266,11 +268,24 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
|
|||||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
|
||||||
if res.Deduplicated {
|
if res.Deduplicated {
|
||||||
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
// Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую»
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
return
|
||||||
|
}
|
||||||
|
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||||
@@ -326,6 +341,19 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
case "delete_confirm":
|
case "delete_confirm":
|
||||||
err = b.reviewer.Delete(ctx, id)
|
err = b.reviewer.Delete(ctx, id)
|
||||||
note = "Удаляю…"
|
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":
|
case "type":
|
||||||
err = b.reviewer.SetType(ctx, id, val)
|
err = b.reviewer.SetType(ctx, id, val)
|
||||||
note = "Меняю тип…"
|
note = "Меняю тип…"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type fakeReviewer struct {
|
|||||||
canceled []string
|
canceled []string
|
||||||
retried []string
|
retried []string
|
||||||
deleted []string
|
deleted []string
|
||||||
|
dismissed []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
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)
|
f.deleted = append(f.deleted, id)
|
||||||
return nil
|
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).
|
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||||
@@ -162,6 +167,40 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дедуп на приёме (дубль на уже активную задачу) → явный ответ «дубль …
|
||||||
|
// добавление отменено», а не «Принято».
|
||||||
|
func TestBot_IngestDeduplicated(t *testing.T) {
|
||||||
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
|
ing.res = ingest.Result{DownloadID: tid, State: store.StateDownloading, 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) || strings.Contains(txt, "Принято") {
|
||||||
|
t.Errorf("ожидалось сообщение о дубле с #%s, got %q", tid, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дедуп на «спящую» 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) {
|
func TestBot_DeniesUnknownUser(t *testing.T) {
|
||||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||||
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
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 {
|
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
row := []tgbotapi.InlineKeyboardButton{
|
row := []tgbotapi.InlineKeyboardButton{
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||||||
|
// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
|
||||||
}
|
}
|
||||||
if url := b.reviewURL(id); url != "" {
|
if url := b.reviewURL(id); url != "" {
|
||||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||||
@@ -169,14 +171,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
|
||||||
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
|
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||||||
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
|
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||||||
|
// ведёт на подтверждение.
|
||||||
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
var row []tgbotapi.InlineKeyboardButton
|
var row []tgbotapi.InlineKeyboardButton
|
||||||
if url := b.reviewURL(id); url != "" {
|
if url := b.reviewURL(id); url != "" {
|
||||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", 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...))
|
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
|
||||||
return &kb
|
return &kb
|
||||||
}
|
}
|
||||||
@@ -191,6 +197,16 @@ func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
|||||||
return &kb
|
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 {
|
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||||
url := b.reviewURL(id)
|
url := b.reviewURL(id)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,10 +15,12 @@ import (
|
|||||||
type fakeNamer struct {
|
type fakeNamer struct {
|
||||||
name string
|
name string
|
||||||
gotContext string
|
gotContext string
|
||||||
|
calls int
|
||||||
onCall func()
|
onCall func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
|
||||||
|
f.calls++
|
||||||
f.gotContext = contextText
|
f.gotContext = contextText
|
||||||
if f.onCall != nil {
|
if f.onCall != nil {
|
||||||
f.onCall()
|
f.onCall()
|
||||||
@@ -77,6 +80,84 @@ func TestProcessCatchedAddsToQbit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее
|
||||||
|
// вручную/другим клиентом) → усыновляем: promote в downloading без повторного
|
||||||
|
// Add (и без 409) и без namer; имя берём из раздачи снимка.
|
||||||
|
func TestProcessCatchedPresentAdopts(t *testing.T) {
|
||||||
|
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||||||
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
|
{Hash: catchedIH, Name: "Dune.2024.1080p"}, // без нашей категории — добавлен вручную
|
||||||
|
}}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
nm := &fakeNamer{name: "не должно вызваться"}
|
||||||
|
w.SetNamer(nm)
|
||||||
|
|
||||||
|
w.processCatched(context.Background())
|
||||||
|
|
||||||
|
if len(qb.added) != 0 {
|
||||||
|
t.Errorf("qbt.Add не должен вызываться для уже присутствующего торрента, calls = %d", len(qb.added))
|
||||||
|
}
|
||||||
|
if nm.calls != 0 {
|
||||||
|
t.Errorf("namer (LLM) не должен вызываться, calls = %d", nm.calls)
|
||||||
|
}
|
||||||
|
d := st.downloads["1"]
|
||||||
|
if d.State != store.StateDownloading {
|
||||||
|
t.Errorf("state = %q, want downloading", d.State)
|
||||||
|
}
|
||||||
|
if d.DisplayName != "Dune.2024.1080p" {
|
||||||
|
t.Errorf("display_name = %q, want имя раздачи из снимка", d.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Гонка F6: список catched снят как magnet, но апгрейд до .torrent случился
|
||||||
|
// между снимком и re-read под замком. processCatched перечитывает source_type
|
||||||
|
// под w.mu, поэтому добавляет файлом (Torrents), а не magnet-ссылкой.
|
||||||
|
func TestProcessCatchedReReadsSourceTypeUnderLock(t *testing.T) {
|
||||||
|
st := catchedStore("1", catchedIH, nowStr, "ctx") // снят как magnet
|
||||||
|
st.torrents = map[string][]byte{}
|
||||||
|
qb := &fakeQbt{} // раздачи нет → absent-ветка (обычное добавление)
|
||||||
|
// Апгрейд «под носом»: между листингом (снимок magnet) и re-read под замком
|
||||||
|
// БД уже стала torrent с сохранёнными байтами.
|
||||||
|
qb.onTorrents = func() {
|
||||||
|
st.downloads["1"].SourceType = store.SourceTorrent
|
||||||
|
st.torrents["1"] = []byte("d4:infod-fake-torrent-bytes-ee")
|
||||||
|
}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
w.SetNamer(&fakeNamer{name: "X"})
|
||||||
|
|
||||||
|
w.processCatched(context.Background())
|
||||||
|
|
||||||
|
if len(qb.added) != 1 {
|
||||||
|
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||||||
|
}
|
||||||
|
if len(qb.added[0].Torrents) != 1 {
|
||||||
|
t.Errorf("после апгрейда ожидалось добавление файлом (Torrents), got Torrents=%v URLs=%v",
|
||||||
|
qb.added[0].Torrents, qb.added[0].URLs)
|
||||||
|
}
|
||||||
|
if len(qb.added[0].URLs) != 0 {
|
||||||
|
t.Errorf("magnet-ссылка не должна использоваться после апгрейда в torrent: %v", qb.added[0].URLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Листинг qBittorrent провалился (недоступен) → пойманную не трогаем: остаётся
|
||||||
|
// catched, ни namer, ни Add не вызываются (повтор на следующем тике).
|
||||||
|
func TestProcessCatchedListErrorKeepsCatched(t *testing.T) {
|
||||||
|
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||||
|
qb := &fakeQbt{torrentsErr: errors.New("connection refused")}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
nm := &fakeNamer{name: "X"}
|
||||||
|
w.SetNamer(nm)
|
||||||
|
|
||||||
|
w.processCatched(context.Background())
|
||||||
|
|
||||||
|
if st.downloads["1"].State != store.StateCatched {
|
||||||
|
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
if nm.calls != 0 || len(qb.added) != 0 {
|
||||||
|
t.Errorf("при недоступности qBit namer/Add не должны вызываться: namer=%d add=%d", nm.calls, len(qb.added))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
||||||
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
||||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||||
|
|||||||
+109
-7
@@ -387,10 +387,34 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(catched) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Снимок присутствия раздач в qBittorrent (один листинг на тик): по нему ДО
|
||||||
|
// вызова namer решаем, добавлять ли задачу вообще. Провал листинга —
|
||||||
|
// qBittorrent недоступен: пойманные в этот тик не трогаем (ни namer, ни Add),
|
||||||
|
// повтор на следующем; устойчивая недоступность отсекается предохранителем
|
||||||
|
// catch_timeout.
|
||||||
|
torrents, err := w.qbt.Torrents(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
w.log.Warn("list torrents for catched failed", "capability", capIngest, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byHash := torrentsByHash(torrents)
|
||||||
|
|
||||||
for _, d := range catched {
|
for _, d := range catched {
|
||||||
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||||
|
|
||||||
// Предохранитель: устойчивая невозможность добавить в qBittorrent.
|
// Торрент уже в qBittorrent — повторный Add не нужен (и вреден: qBittorrent
|
||||||
|
// отверг бы дубль, 409) и LLM-namer не зовём: усыновляем раздачу, доводя
|
||||||
|
// задачу до downloading. См. promoteExisting.
|
||||||
|
if t, ok := torrentFor(d, byHash); ok {
|
||||||
|
w.promoteExisting(cctx, d, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предохранитель: устойчивая невозможность добавить в qBittorrent (раздачи
|
||||||
|
// в снимке нет и висит дольше catch_timeout).
|
||||||
if w.cfg.CatchTimeout > 0 {
|
if w.cfg.CatchTimeout > 0 {
|
||||||
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -406,9 +430,20 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
|
// Раздачи в qBittorrent нет — обычный путь добавления. Перечитываем запись
|
||||||
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
|
// под замком: (а) актуальный source_type (апгрейд magnet→torrent мог
|
||||||
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
|
// случиться после снятия списка catched — иначе добавили бы magnet из
|
||||||
|
// устаревшего снимка), (б) ре-валидация state=catched. Тяжёлые вызовы
|
||||||
|
// (чтение байтов, namer, Add) — вне замка.
|
||||||
|
w.mu.Lock()
|
||||||
|
cur, gerr := w.store.GetDownload(cctx, d.ID)
|
||||||
|
fresh := gerr == nil && cur != nil && cur.State == store.StateCatched
|
||||||
|
w.mu.Unlock()
|
||||||
|
if !fresh {
|
||||||
|
continue // отменили/пропала, пока шёл листинг — не трогаем
|
||||||
|
}
|
||||||
|
|
||||||
|
hint, addReq, prepErr := w.sourceAddParts(cctx, *cur)
|
||||||
if prepErr != nil {
|
if prepErr != nil {
|
||||||
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
||||||
// остаёмся в catched, повтор на следующем тике.
|
// остаёмся в catched, повтор на следующем тике.
|
||||||
@@ -417,13 +452,13 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
var rename string
|
var rename string
|
||||||
if w.namer != nil {
|
if w.namer != nil {
|
||||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
rename = w.namer.DeriveName(cctx, cur.Context, hint)
|
||||||
}
|
}
|
||||||
addReq.Rename = rename
|
addReq.Rename = rename
|
||||||
addErr := w.qbt.Add(cctx, addReq)
|
addErr := w.qbt.Add(cctx, addReq)
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
|
||||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
// повтор на следующем тике. Вызов qBit уже залогировал клиент (ext.*).
|
||||||
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -441,6 +476,42 @@ func (w *Worker) processCatched(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promoteExisting усыновляет пойманную загрузку, чей торрент уже присутствует в
|
||||||
|
// qBittorrent (снимок тика): переводит catched → downloading БЕЗ повторного Add
|
||||||
|
// (иначе qBittorrent отверг бы дубль — 409 — и задача зациклилась бы) и без LLM.
|
||||||
|
// Имя берём из раздачи снимка (t.Name); у свежего magnet без метаданных (metaDL)
|
||||||
|
// оно может быть пустым — распознавание дольёт имя позже. Инвариант приёма
|
||||||
|
// гарантирует, что сюда доходит лишь загрузка без другой активной задачи на тот
|
||||||
|
// же infohash, поэтому присутствие раздачи трактуем как «усыновить и разложить»,
|
||||||
|
// а не как конфликт. Короткий DB-переход под w.mu; атомарный гард PromoteCatched
|
||||||
|
// (WHERE state='catched') сам отсекает гонку отмены, случившуюся, пока шёл листинг
|
||||||
|
// вне замка, — отдельный re-read не нужен.
|
||||||
|
func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if err := w.store.PromoteCatched(ctx, d.ID, t.Name); err != nil {
|
||||||
|
logctx.From(ctx).Info("catched promote skipped", "reason", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logctx.From(ctx).Info("state transition", "from", store.StateCatched,
|
||||||
|
"to", store.StateDownloading, "reason", "already present in qbittorrent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// torrentsByHash индексирует раздачи по каждому из их хешей (lowercase), как это
|
||||||
|
// делает Poll для своего снимка. Использует processCatched, чтобы проверить, есть
|
||||||
|
// ли торрент пойманной загрузки уже в qBittorrent.
|
||||||
|
func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent {
|
||||||
|
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
||||||
|
for _, t := range torrents {
|
||||||
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||||
|
if h != "" {
|
||||||
|
byHash[strings.ToLower(h)] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byHash
|
||||||
|
}
|
||||||
|
|
||||||
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
|
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
|
||||||
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
|
||||||
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
||||||
@@ -826,6 +897,37 @@ func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss — универсальный стоп-кран: переводит задачу в терминальный cancelled из
|
||||||
|
// ЛЮБОГО состояния, кроме deleted, ТОЛЬКО меняя статус. В отличие от Delete не
|
||||||
|
// трогает ни файлы (библиотечные хардлинки done/orphaned остаются на месте), ни
|
||||||
|
// раздачу в qBittorrent, ни цель; source-preflight не делает. Служит закрытием
|
||||||
|
// зависшей/спорной/лишней записи (в т.ч. дубля-близнеца в target_missing). Из
|
||||||
|
// cancelled — идемпотентный no-op БЕЗ setState: иначе перезаписал бы error_code,
|
||||||
|
// подменив причину прежнего Cancel/Dismiss. Помечает переход user_dismiss
|
||||||
|
// (отличает от reconcile и от штатного Cancel с пустым кодом).
|
||||||
|
func (w *Worker) Dismiss(ctx context.Context, id string) (err error) {
|
||||||
|
defer func() { w.logCmd(ctx, "dismiss", id, err) }()
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
d, err := w.store.GetDownload(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dismiss: %w", err)
|
||||||
|
}
|
||||||
|
if d.State == store.StateDeleted {
|
||||||
|
return fmt.Errorf("dismiss: download %s is deleted (strictly terminal): %w", id, ErrConflict)
|
||||||
|
}
|
||||||
|
if d.State == store.StateCancelled {
|
||||||
|
return nil // уже закрыта — no-op, error_code прежней отмены не трогаем
|
||||||
|
}
|
||||||
|
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "user_dismiss", "закрыто пользователем"); err != nil {
|
||||||
|
return fmt.Errorf("dismiss: %w", err)
|
||||||
|
}
|
||||||
|
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||||
|
"from", d.State, "to", store.StateCancelled, "code", "user_dismiss")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||||
// qBittorrent и возвращает в downloading.
|
// qBittorrent и возвращает в downloading.
|
||||||
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { r
|
|||||||
type fakeQbt struct {
|
type fakeQbt struct {
|
||||||
torrents []qbt.Torrent
|
torrents []qbt.Torrent
|
||||||
torrentsErr error
|
torrentsErr error
|
||||||
|
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||||
added []qbt.AddRequest
|
added []qbt.AddRequest
|
||||||
addErr error
|
addErr error
|
||||||
files []qbt.File
|
files []qbt.File
|
||||||
@@ -288,6 +289,9 @@ type renameCall struct {
|
|||||||
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
||||||
// теряться при поиске по infohash.
|
// теряться при поиске по infohash.
|
||||||
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
||||||
|
if f.onTorrents != nil {
|
||||||
|
f.onTorrents()
|
||||||
|
}
|
||||||
if f.torrentsErr != nil {
|
if f.torrentsErr != nil {
|
||||||
return nil, f.torrentsErr
|
return nil, f.torrentsErr
|
||||||
}
|
}
|
||||||
@@ -442,6 +446,58 @@ func TestCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss — стоп-кран: переводит в cancelled только сменой статуса, не трогая
|
||||||
|
// раздачу и файлы, из любого состояния, кроме deleted; из cancelled — no-op без
|
||||||
|
// перезаписи error_code.
|
||||||
|
func TestDismiss(t *testing.T) {
|
||||||
|
t.Run("from-done-keeps-source-and-files", func(t *testing.T) {
|
||||||
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
|
st.downloads["1"].State = store.StateDone
|
||||||
|
qb := &fakeQbt{}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
if err := w.Dismiss(context.Background(), "1"); err != nil {
|
||||||
|
t.Fatalf("Dismiss: %v", err)
|
||||||
|
}
|
||||||
|
if st.downloads["1"].State != store.StateCancelled {
|
||||||
|
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
if got := st.downloads["1"].ErrorCode.String; got != "user_dismiss" {
|
||||||
|
t.Errorf("error_code = %q, want user_dismiss", got)
|
||||||
|
}
|
||||||
|
if len(qb.deleted) != 0 {
|
||||||
|
t.Errorf("раздачу трогать не должны, Delete вызван %d раз", len(qb.deleted))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("from-deleted-rejected", func(t *testing.T) {
|
||||||
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
|
st.downloads["1"].State = store.StateDeleted
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
if err := w.Dismiss(context.Background(), "1"); err == nil {
|
||||||
|
t.Error("ожидалась ошибка Dismiss из deleted")
|
||||||
|
}
|
||||||
|
if st.downloads["1"].State != store.StateDeleted {
|
||||||
|
t.Errorf("state = %q, want deleted (не изменилось)", st.downloads["1"].State)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("from-cancelled-noop-keeps-code", func(t *testing.T) {
|
||||||
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
|
st.downloads["1"].State = store.StateCancelled
|
||||||
|
st.downloads["1"].ErrorCode = store.NullString("prior_reason")
|
||||||
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
|
if err := w.Dismiss(context.Background(), "1"); err != nil {
|
||||||
|
t.Fatalf("Dismiss no-op: %v", err)
|
||||||
|
}
|
||||||
|
if got := st.downloads["1"].ErrorCode.String; got != "prior_reason" {
|
||||||
|
t.Errorf("error_code = %q, no-op не должен его переписывать", got)
|
||||||
|
}
|
||||||
|
if len(st.transitions) != 0 {
|
||||||
|
t.Errorf("no-op не должен писать переход, got %d", len(st.transitions))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRetry(t *testing.T) {
|
func TestRetry(t *testing.T) {
|
||||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||||
st.downloads["1"].State = store.StateStuck
|
st.downloads["1"].State = store.StateStuck
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-10
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
`processCatched` (`internal/worker/worker.go`) вызывается из `pollOnce` сразу
|
||||||
|
после `Poll`. Сейчас он безусловно зовёт namer (LLM) и `qbt.Add`, а `409`/`Fails.`
|
||||||
|
на дубле трактует как транзиентный сбой → вечный повтор с тратой LLM.
|
||||||
|
|
||||||
|
Приём (`ingest`) уже дедуплицирует по infohash на **активную** задачу
|
||||||
|
(`FindActiveByInfohash`/`CreateDownloadIfNoActive`). Значит до `processCatched`
|
||||||
|
доходит только загрузка, для которой в jellybit нет другой активной задачи.
|
||||||
|
Отсюда упрощение: если торрент такой загрузки уже присутствует в qBittorrent,
|
||||||
|
это не «конфликт с чужой задачей», а «раздачу уже кто-то (пользователь вручную,
|
||||||
|
прошлый тик) добавил» — надо просто **усыновить** её и разложить.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Пойманная загрузка, чей торрент уже в qBittorrent, доводится до `downloading`
|
||||||
|
без повторного `add` (без 409) и без LLM; дальше — обычная раскладка.
|
||||||
|
- LLM-namer не вызывается ни при усыновлении, ни при недоступности qBittorrent.
|
||||||
|
- Гонка апгрейда F6 сужена перечитыванием `source_type` под блокировкой.
|
||||||
|
- Повторное добавление уже активной в jellybit загрузки транспорт отражает как
|
||||||
|
дубль (сообщение + лог), без новой записи.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Не заводим новых состояний загрузки. Дедуп на приёме записи не создаёт;
|
||||||
|
усыновление — это `downloading`, а не отдельный статус.
|
||||||
|
- Не различаем «наш/чужой» торрент по категории/тегу: инвариант приёма делает
|
||||||
|
различие ненужным (до воркера доходит лишь загрузка без другой активной).
|
||||||
|
- Не добавляем счётчик попыток `add` (предел — время `catch_timeout`).
|
||||||
|
- Не проверяем присутствие на приёме (`ingest` остаётся быстрым, без qBittorrent).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Источник снимка присутствия: один листинг `qbt.Torrents("")` на входе в
|
||||||
|
`processCatched`.** Строим `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`,
|
||||||
|
lowercase), переиспользуем для всех catched-задач тика (как это делает Poll для
|
||||||
|
своего снимка). Провал листинга → qBittorrent недоступен → в этот тик пойманные
|
||||||
|
не трогаем (namer не зовём), повтор на следующем; отсечка — `catch_timeout`.
|
||||||
|
Поиск торрента задачи — существующий `torrentFor(d, byHash)`.
|
||||||
|
|
||||||
|
**2. Присутствует → усыновляем; ветвление до namer.** Если `torrentFor` нашёл
|
||||||
|
раздачу — `PromoteCatched(id, t.Name)` (перевод `catched → downloading` + имя из
|
||||||
|
`qbt.Torrent.Name`, без LLM), под коротким замком с ре-валидацией `state='catched'`.
|
||||||
|
Иначе — обычный путь: re-read под замком → namer → `sourceAddParts` → `Add` →
|
||||||
|
`PromoteCatched`. namer (LLM) на ветке усыновления и при недоступности qBit не
|
||||||
|
зовётся.
|
||||||
|
|
||||||
|
**3. Никакого различия «наш/чужой» и никакого `duplicated`.** Инвариант приёма
|
||||||
|
(«одна активная на infohash») гарантирует, что усыновляемая раздача не отберётся
|
||||||
|
у другой активной задачи. `PromoteCatched` (гард `state='catched'`) корректен;
|
||||||
|
`ActivateIfNoOtherActive` (как в `Retry` из терминального `failed`) здесь не
|
||||||
|
нужен — `catched` нетерминален и уже единственный активный владелец infohash.
|
||||||
|
Имя раздачи в `display_name` полезно и уведомлениям, и заголовку в UI (у `catched`
|
||||||
|
оно пусто).
|
||||||
|
|
||||||
|
**4. Re-read `source_type` под замком перед добавлением (сужение гонки F6).**
|
||||||
|
На absent-ветке перед сбором `addReq` берём короткий замок, перечитываем запись
|
||||||
|
(`GetDownload`): ре-валидация `state='catched'` и актуальный `source_type`
|
||||||
|
(апгрейд magnet→torrent мог случиться после снятия списка). Тяжёлые вызовы
|
||||||
|
(`GetTorrentData`, namer, `Add`) — вне замка.
|
||||||
|
|
||||||
|
**5. Дедуп на приёме (case 1) — только сообщение.** `ingest` при попадании на
|
||||||
|
активную задачу уже возвращает `Deduplicated=true` (запись не создаётся). Меняем
|
||||||
|
лишь текст ответа транспорта: вместо «Уже в работе #id» — «♻️ дубль уже активной
|
||||||
|
#id, добавление отменено». Лог дедупа (`download attached to active`) уже есть.
|
||||||
|
Новых состояний/записей не заводим.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Остаточное окно F6]** → re-read `source_type` под замком + `Add` вне замка
|
||||||
|
окно резко **сужают**, но не закрывают полностью. Полное закрытие требует
|
||||||
|
держать замок через `Add`, что нарушает инвариант «тяжёлые вызовы вне замка».
|
||||||
|
Оверлап крайне редок, цена промаха — один неудачный magnet-add, повтор на
|
||||||
|
следующем тике уже увидит `torrent`. Принимаем суженное окно осознанно.
|
||||||
|
- **[Снимок присутствия на тик «отстаёт»]** → каждая catched-задача
|
||||||
|
обрабатывается раз за тик; если наш `add` прошёл, а запись перехода сорвалась,
|
||||||
|
усыновление случится на следующем тике, где листинг уже видит раздачу.
|
||||||
|
- **[Усыновление раздачи, добавленной вручную с иными savepath/категорией]** →
|
||||||
|
инвариант источника не нарушается: файлы не наши, раскладка хардлинчит
|
||||||
|
отдельно; infohash совпадает — контент тот же. Осознанное поведение (как
|
||||||
|
`discover`).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Пойманная (`catched`) загрузка, чей торрент **уже присутствует в qBittorrent**
|
||||||
|
(добавлен раньше вручную/другим клиентом или прошлой попыткой jellybit), уходит в
|
||||||
|
бесконечный цикл: `processCatched` на каждом тике зовёт `qbt.Add`, qBittorrent
|
||||||
|
отбивает дубль (`409 Conflict`), сбой трактуется как транзиентный → задача
|
||||||
|
остаётся в `catched` → повтор, и на каждом безнадёжном тике впустую вызывается
|
||||||
|
LLM-namer. Диагноз: `docs/backlog/review-f2-promote-without-add.md`.
|
||||||
|
|
||||||
|
Решение: перед добавлением проверять присутствие торрента в qBittorrent. Раз
|
||||||
|
инвариант приёма гарантирует, что до воркера доходит лишь загрузка, для которой в
|
||||||
|
jellybit нет другой активной задачи (дубль на активную отсекается ещё на приёме),
|
||||||
|
присутствие торрента в qBittorrent означает «его надо **усыновить**» — довести до
|
||||||
|
`downloading` без повторного `add` и разложить, а не пытаться добавить дубль и
|
||||||
|
ловить 409.
|
||||||
|
|
||||||
|
Отдельно: повторное добавление торрента, который jellybit **уже ведёт активной
|
||||||
|
задачей**, транспорт должен явно отражать как дубль (сообщение «добавление
|
||||||
|
отменено»), а не молчаливым «уже в работе».
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- В `processCatched` перед `qbt.Add` — **проверка присутствия торрента в
|
||||||
|
qBittorrent** (один листинг на тик). Присутствует → `catched → downloading`
|
||||||
|
**без `add`** (усыновление; `display_name` из имени раздачи, без LLM); нет →
|
||||||
|
прежний путь добавления. Проверка — **до namer**, чтобы не жечь LLM.
|
||||||
|
- При недоступности qBittorrent (листинг не удался) тик пропускается без вызова
|
||||||
|
LLM; предел ретрая — существующий предохранитель `catch_timeout`.
|
||||||
|
- Гонка апгрейда F6 сужается: `source_type` перечитывается под блокировкой
|
||||||
|
переходов перед добавлением.
|
||||||
|
- Транспорт Telegram на дедуп приёма (дубль на уже активную задачу) отвечает
|
||||||
|
явным «дубль уже активной #id — добавление отменено» (+ лог), без создания
|
||||||
|
новой записи.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
<!-- нет новых capability -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent»
|
||||||
|
дополняется проверкой присутствия и усыновлением (promote без повторного
|
||||||
|
`add`) при наличии торрента, перечитыванием источника под замком и пропуском
|
||||||
|
тика при недоступности qBittorrent.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Код: `internal/worker/worker.go` (`processCatched`, presence-check,
|
||||||
|
усыновление вместо повторного `add`), `internal/tgbot/bot.go` (текст ответа на
|
||||||
|
дедуп).
|
||||||
|
- Внешние вызовы: убирает лишние `qbt.Add` (и 409) и `chat.completions`
|
||||||
|
(LLM-namer) на повторах; добавляет один `qbt.Torrents`-листинг на тик в
|
||||||
|
`processCatched`.
|
||||||
|
- Тесты: `internal/worker/catched_test.go`.
|
||||||
|
- **БД-миграции, новых состояний, конфигурации и внешнего API — нет.**
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||||
|
|
||||||
|
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||||
|
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
|
||||||
|
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||||||
|
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||||||
|
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||||||
|
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||||||
|
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
|
||||||
|
(которое и означает «в qBit, возможно `metaDL`»).
|
||||||
|
|
||||||
|
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
|
||||||
|
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
|
||||||
|
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
|
||||||
|
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
|
||||||
|
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
|
||||||
|
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
|
||||||
|
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
|
||||||
|
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
|
||||||
|
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
|
||||||
|
загрузку, которую добавлять не требуется.
|
||||||
|
|
||||||
|
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
|
||||||
|
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
|
||||||
|
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
|
||||||
|
«усыновить и разложить», а не как конфликт с чужой задачей.
|
||||||
|
|
||||||
|
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
|
||||||
|
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
|
||||||
|
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
|
||||||
|
(см. «Предохранитель зависшего catched»).
|
||||||
|
|
||||||
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
|
(`source_type`):
|
||||||
|
|
||||||
|
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||||
|
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||||
|
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||||
|
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||||
|
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||||
|
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||||
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
|
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||||
|
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
||||||
|
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
||||||
|
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
||||||
|
устаревшего снимка, хотя БД уже `torrent`.
|
||||||
|
|
||||||
|
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||||
|
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||||
|
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||||||
|
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||||||
|
|
||||||
|
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||||
|
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||||
|
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||||
|
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||||||
|
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||||
|
параллельной отмене).
|
||||||
|
|
||||||
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||||
|
ещё нет в qBittorrent
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
|
нашей категорией и `rename`
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
|
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||||||
|
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
|
||||||
|
прошлом тике, а запись перехода не удалась)
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
|
||||||
|
- **AND** `display_name` записывается из имени присутствующей раздачи
|
||||||
|
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
|
||||||
|
|
||||||
|
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
|
||||||
|
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
|
||||||
|
|
||||||
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||||||
|
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
|
||||||
|
- **THEN** загрузка остаётся в `catched`
|
||||||
|
- **AND** на следующем тике попытка добавления повторяется
|
||||||
|
|
||||||
|
#### Scenario: Отмена во время добавления
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||||
|
блокировки
|
||||||
|
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||||
|
worker берёт блокировку для записи перехода
|
||||||
|
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||||
|
`downloading` не применяется
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## 1. Усыновление в processCatched
|
||||||
|
|
||||||
|
- [x] 1.1 В начале `processCatched` один раз получить листинг `qbt.Torrents("")`
|
||||||
|
и построить `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`, lowercase); провал
|
||||||
|
листинга → WARN и ранний выход (пойманные не трогаем этот тик, namer не зовём).
|
||||||
|
- [x] 1.2 Для каждой catched-задачи: `torrentFor(d, byHash)`. Присутствует →
|
||||||
|
усыновление БЕЗ namer/Add: под замком с ре-валидацией `state='catched'`
|
||||||
|
`PromoteCatched(id, t.Name)` (`catched → downloading`, имя из раздачи снимка).
|
||||||
|
- [x] 1.3 Отсутствует в `byHash` — прежний путь, но с re-read записи под `w.mu`
|
||||||
|
перед сбором `addReq` (свежий `source_type`, ре-валидация `state='catched'`);
|
||||||
|
тяжёлые вызовы (`GetTorrentData`, namer, `qbt.Add`) — вне замка.
|
||||||
|
- [x] 1.4 Убедиться, что namer и `qbt.Add` не вызываются на ветке присутствия и
|
||||||
|
при провале листинга.
|
||||||
|
|
||||||
|
## 2. Сообщение о дубле на приёме
|
||||||
|
|
||||||
|
- [x] 2.1 В `internal/tgbot/bot.go` (`ingestAndReply`) на `res.Deduplicated`
|
||||||
|
отвечать явным «♻️ Дубль уже активной загрузки #id — добавление отменено»
|
||||||
|
(вместо «Уже в работе #id»). Лог дедупа в `ingest` уже есть.
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
- [x] 3.1 `catched_test.go`: торрент присутствует в снимке qBittorrent →
|
||||||
|
`downloading` без `Add` и без namer; `display_name` = имя раздачи. (Фейк qBit
|
||||||
|
отдаёт торрент в снимке ДО обработки задачи.)
|
||||||
|
- [x] 3.2 `catched_test.go`: листинг qBittorrent провалился → задача осталась
|
||||||
|
`catched`, namer/Add не вызывались.
|
||||||
|
- [x] 3.3 `catched_test.go`: торрента нет в снимке → обычный путь (namer + Add +
|
||||||
|
promote) остаётся зелёным; re-read `source_type` под замком берёт актуальный тип.
|
||||||
|
- [x] 3.4 Регресс: catch_timeout-предохранитель, отмена во время добавления.
|
||||||
|
|
||||||
|
## 4. Проверки и ревью
|
||||||
|
|
||||||
|
- [x] 4.1 `task test` и `task lint` зелёные.
|
||||||
|
- [x] 4.2 Ревью кода (чекпоинт перед archive): jellybit-review-code +
|
||||||
|
jellybit-review-specs (сверка со спекой download-tracking).
|
||||||
|
- [x] 4.3 `openspec validate --strict catched-promote-without-readd`.
|
||||||
@@ -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`.
|
||||||
@@ -95,12 +95,34 @@ Worker SHALL периодически сверять раздачи qBittorrent
|
|||||||
### Requirement: Добавление пойманной загрузки в qBittorrent
|
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||||
|
|
||||||
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||||
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
|
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
|
||||||
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
|
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
|
||||||
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
|
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
|
||||||
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
|
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
|
||||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
|
||||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
|
||||||
|
(которое и означает «в qBit, возможно `metaDL`»).
|
||||||
|
|
||||||
|
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
|
||||||
|
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
|
||||||
|
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
|
||||||
|
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
|
||||||
|
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
|
||||||
|
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
|
||||||
|
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
|
||||||
|
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
|
||||||
|
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
|
||||||
|
загрузку, которую добавлять не требуется.
|
||||||
|
|
||||||
|
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
|
||||||
|
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
|
||||||
|
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
|
||||||
|
«усыновить и разложить», а не как конфликт с чужой задачей.
|
||||||
|
|
||||||
|
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
|
||||||
|
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
|
||||||
|
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
|
||||||
|
(см. «Предохранитель зависшего catched»).
|
||||||
|
|
||||||
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||||
(`source_type`):
|
(`source_type`):
|
||||||
@@ -114,21 +136,28 @@ Worker SHALL периодически (в поллинг-цикле, под ед
|
|||||||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||||
magnet-хешу вместо файла система SHALL NOT.
|
magnet-хешу вместо файла система SHALL NOT.
|
||||||
|
|
||||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
`source_type` для выбора способа добавления worker SHALL перечитывать **под
|
||||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
|
||||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
|
||||||
тиками поллинга).
|
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
|
||||||
|
устаревшего снимка, хотя БД уже `torrent`.
|
||||||
|
|
||||||
|
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
|
||||||
|
загрузку в `catched` для повторной попытки на следующем тике; переход в
|
||||||
|
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
|
||||||
|
естественными тиками поллинга, отсечка — `catch_timeout`).
|
||||||
|
|
||||||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||||
downloading` (см. «Переходы состояний сериализуются воркером»), с
|
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
|
||||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
|
||||||
например, при параллельной отмене).
|
параллельной отмене).
|
||||||
|
|
||||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
|
||||||
|
ещё нет в qBittorrent
|
||||||
- **WHEN** worker обрабатывает тик
|
- **WHEN** worker обрабатывает тик
|
||||||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||||
нашей категорией и `rename`
|
нашей категорией и `rename`
|
||||||
@@ -137,16 +166,33 @@ downloading` (см. «Переходы состояний сериализуют
|
|||||||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||||
|
|
||||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||||
сохранёнными байтами файла
|
сохранёнными байтами файла, торрента ещё нет в qBittorrent
|
||||||
- **WHEN** worker обрабатывает тик
|
- **WHEN** worker обрабатывает тик
|
||||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||||
- **AND** загрузка переходит в `downloading`
|
- **AND** загрузка переходит в `downloading`
|
||||||
|
|
||||||
|
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
|
||||||
|
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
|
||||||
|
прошлом тике, а запись перехода не удалась)
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
|
||||||
|
- **AND** `display_name` записывается из имени присутствующей раздачи
|
||||||
|
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
|
||||||
|
|
||||||
|
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
|
||||||
|
- **WHEN** worker обрабатывает тик
|
||||||
|
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
|
||||||
|
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
|
||||||
|
|
||||||
#### Scenario: Временный сбой добавления — повтор
|
#### Scenario: Временный сбой добавления — повтор
|
||||||
|
|
||||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
|
||||||
- **WHEN** worker пытается добавить источник и `add` не удался
|
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
|
||||||
- **THEN** загрузка остаётся в `catched`
|
- **THEN** загрузка остаётся в `catched`
|
||||||
- **AND** на следующем тике попытка добавления повторяется
|
- **AND** на следующем тике попытка добавления повторяется
|
||||||
|
|
||||||
|
|||||||
@@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name
|
|||||||
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||||
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||||
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||||
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
|
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
|
||||||
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
|
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
|
||||||
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
|
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
|
||||||
выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
|
||||||
|
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
|
||||||
|
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||||
состояние»).
|
состояние»).
|
||||||
|
|
||||||
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||||
@@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
|||||||
|
|
||||||
### Requirement: Дедупликация приёма по любому из хешей
|
### Requirement: Дедупликация приёма по любому из хешей
|
||||||
|
|
||||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
|
||||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
|
||||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
|
||||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
|
||||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
|
||||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
|
||||||
выводится только из `state`.
|
`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: Повторный приём при активной загрузке
|
#### Scenario: Повторный приём при активной загрузке
|
||||||
|
|
||||||
@@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
|||||||
- **WHEN** принимается magnet с тем же `h`
|
- **WHEN** принимается magnet с тем же `h`
|
||||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
- **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: Повторный приём после завершения
|
#### Scenario: Повторный приём после завершения
|
||||||
|
|
||||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
|
||||||
- **WHEN** принимается magnet с тем же `h`
|
- **WHEN** принимается magnet с тем же `h`
|
||||||
- **THEN** создаётся новая загрузка со своим ULID и записью `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: Атомарность возврата загрузки в активное состояние
|
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||||
|
|
||||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||||
|
|||||||
@@ -534,3 +534,68 @@ SHALL задевать раскладку в полёте.
|
|||||||
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||||
- **AND** повторный delete идемпотентно дожимает удаление
|
- **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`)
|
||||||
|
|
||||||
|
|||||||
@@ -92,13 +92,27 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Опасная зона: полное удаление загрузки (снять хардлинки + снести раздачу с
|
<!-- Опасная зона: закрытие (только смена статуса) и полное удаление (снять
|
||||||
файлами из qBittorrent). Секция скрыта в details (раскрыть сознательно). С JS
|
хардлинки + снести раздачу). Секция скрыта в details (раскрыть сознательно).
|
||||||
hx-confirm показывает диалог подтверждения; без JS гейт — только раскрытие
|
С JS hx-confirm показывает диалог подтверждения; без JS гейт — только
|
||||||
details и явный submit (диалога нет). -->
|
раскрытие details и явный submit (диалога нет). -->
|
||||||
{{if .Deletable}}
|
{{if or .Dismissable .Deletable}}
|
||||||
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
|
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
|
||||||
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
|
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
|
||||||
|
{{if .Dismissable}}
|
||||||
|
<p class="muted" style="margin:var(--sp-3) 0">
|
||||||
|
Закрытие снимет запись из активного списка (перевод в «отменено») —
|
||||||
|
<strong>файлы и раздачу не трогает</strong>. Стоп-кран для зависшей, спорной
|
||||||
|
или лишней загрузки; при необходимости позже можно привязать заново.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/dismiss"
|
||||||
|
hx-post="/ui/downloads/{{.ID}}/dismiss" hx-target="#download-main" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Закрыть загрузку? Запись уйдёт в «отменено». Файлы и раздача останутся нетронутыми.">
|
||||||
|
<input type="hidden" name="surface" value="download">
|
||||||
|
<button class="btn btn-danger" type="submit">Закрыть загрузку</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .Deletable}}
|
||||||
<p class="muted" style="margin:var(--sp-3) 0">
|
<p class="muted" style="margin:var(--sp-3) 0">
|
||||||
Полное удаление снимет библиотечные хардлинки <strong>и</strong> снесёт раздачу
|
Полное удаление снимет библиотечные хардлинки <strong>и</strong> снесёт раздачу
|
||||||
с файлами из qBittorrent — освободит место. Действие необратимо.
|
с файлами из qBittorrent — освободит место. Действие необратимо.
|
||||||
@@ -109,6 +123,7 @@
|
|||||||
<input type="hidden" name="surface" value="download">
|
<input type="hidden" name="surface" value="download">
|
||||||
<button class="btn btn-danger" type="submit">Удалить загрузку</button>
|
<button class="btn btn-danger" type="submit">Удалить загрузку</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</details>
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user