Compare commits

..
2 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 d190072647 Единый беклог задач вместо todo.md и ideas.md (docs)
Слил docs/todo.md и docs/drafts/ideas.md в docs/backlog.md: единый список
будущих задач по приоритетам (Высокий/Средний/Низкий), спекулятивные пункты
помечены _(идея)_. Реализованное из ideas.md (повторное распознавание,
нотификации) не переносил.

Добавил задачи: переработка ревью (выбор источника совпадения с
предпросмотром), главная как список карточек вместо таблицы, отдельная
страница просмотра загрузки (поднял из «Расширенной информации»), скрытие
deleted-загрузок по умолчанию.

Ссылки на drafts/ideas.md из docs/specs/* перенаправлены на backlog.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:31:27 +03:00
avandClaude Opus 4.8 70d8758646 Восстановление зависших загрузок и уведомления о падении (state-reconciliation)
Долгий metaDL больше не убивается агрессивным таймаутом: дефолт
magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта —
added_on из qBittorrent, а не created_at (переживает retry/усыновление).

Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по
нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и
продвинулся за условие падения (downloading/completed по статусу торрента);
qbit_error не воскрешается. Конфликт idempotency (infohash занят другой
активной задачей) — оставляем в failed.

Уведомления: любой переход в failed/stuck пингует автора (включая приёмный
qbit_add через ingest), с дебаунсом против спама при флаппинге stalled.
Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому
торренту вместо слепого Add.

Дельта state-reconciliation влита в живые спеки; обновлён workflow.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:51:57 +03:00
30 changed files with 1294 additions and 113 deletions
+4
View File
@@ -182,6 +182,10 @@ func runServe(args []string) error {
WebBaseURL: cfg.Telegram.WebBaseURL, WebBaseURL: cfg.Telegram.WebBaseURL,
}, logger) }, logger)
wrk.SetNotifier(bot) wrk.SetNotifier(bot)
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
ingestor.SetFailureNotifier(func(id int64) {
bot.Notify(context.Background(), id, worker.EventFailed)
})
go bot.Run(ctx) go bot.Run(ctx)
logger.Info("telegram bot enabled", logger.Info("telegram bot enabled",
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs)) "bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
+1 -1
View File
@@ -62,7 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
[worker] [worker]
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition] [recognition]
+121 -26
View File
@@ -1,13 +1,13 @@
# TODO # Беклог
Конкретные задачи на будущее, ранжированные по приоритету. Это не план Единый список будущих задач по проекту: то, что уже решили сделать, и
реализации (он — в [drafts/roadmap.md](drafts/roadmap.md)) и не свалка идеи, которые ещё надо обдумать. Это не план реализации (он — в
идей ([drafts/ideas.md](drafts/ideas.md)): сюда попадает то, что уже решили [drafts/roadmap.md](drafts/roadmap.md)) и не источник истины: принятое и
сделать, но ещё не сделали. Принятое и реализованное переезжает в реализованное переезжает в `docs/specs`/`docs/adr`.
`docs/specs`/`docs/adr`.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к Приоритет — грубая оценка «ценность / стоимость», не обязательство к
порядку. порядку. Спекулятивные пункты (ещё без решения «делаем») помечены
_(идея)_ — их сперва надо проработать.
## Высокий ## Высокий
@@ -45,6 +45,32 @@ qBittorrent с файлами), подтверждение осознаннос
[architecture.md](specs/architecture.md) → «Раскладка файлов», [architecture.md](specs/architecture.md) → «Раскладка файлов»,
[workflow.md](specs/workflow.md). [workflow.md](specs/workflow.md).
### Ревью: выбор источника совпадения и предпросмотр
Переработать страницу ревью так, чтобы показывать **все** совпавшие
результаты по метабазам списком и дать выбрать из них. Принцип: совпадение
есть **всегда** — мы лишь выбираем источник. Поэтому матч нейронки — это
отдельная строка в том же списке (наравне с кандидатами TMDB/TVDB), а не
особый режим.
Возможности экрана:
- список кандидатов из баз + строка «распознано нейронкой»;
- выбрать один кандидат, переключиться на другой, отменить матч с базой в
пользу нейронки;
- добавить кандидат вручную (по id/url базы), когда автопоиск промахнулся;
- при выборе/переключении — **предпросмотр полей** (название, режиссёр,
год) и **предпросмотр раскладки** (целевые пути) до применения.
Развивает «показывать матч с записью метабазы» (web-сторона), пересекается
с «Расширенной информацией о загрузке» (детальный экран) и быстрым выбором
в Telegram. Веб остаётся точкой точных правок.
Связано: [review-ux.md](specs/review-ux.md) (выбор кандидата, «без базы»,
переключатель типа), [recognition.md](specs/recognition.md) (кандидаты
матча, провайдер-id), [«Улучшения UI: показывать матч»](#улучшения-ui-показывать-матч-с-записью-метабазы),
пакет `httpapi`.
### Наблюдаемость: метрики и учёт стоимости LLM ### Наблюдаемость: метрики и учёт стоимости LLM
Сейчас единственное окно в систему — `slog`. Нет быстрых ответов на Сейчас единственное окно в систему — `slog`. Нет быстрых ответов на
@@ -98,6 +124,33 @@ qBittorrent с файлами), подтверждение осознаннос
## Средний ## Средний
### Главная: список загрузок вместо таблицы
Переделать главную страницу из таблицы в **список** карточек. Для каждой
загрузки: название (как распознали при добавлении в qBittorrent), `infohash`
с кнопкой быстрого копирования, статус и кнопки действий. Контекст (исходное
сообщение/magnet) спрятать под спойлер или вынести на отдельный детальный
экран (см. «Расширенная информация о загрузке»). Естественно сочетается с
фильтром/поиском/пагинацией по мере роста БД.
Связано: [«Расширенная информация о загрузке в web-UI»](#расширенная-информация-о-загрузке-в-web-ui),
[«Список загрузок: фильтр, поиск, пагинация»](#список-загрузок-фильтр-поиск-пагинация),
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
### Расширенная информация о загрузке в web-UI
Отдельная страница просмотра одной загрузки — целиком отображение того, что
лежит в БД по задаче: актуальный статус (текущее состояние + лог переходов),
исходный контекст и magnet, распознанные данные и матч в метабазе (см.
«показывать матч»), а также **точная раскладка, если есть** — целевые пути и
созданные хардлинки. Помогает разбираться, когда что-то пошло не так, без
чтения логов сервера. Сюда же выносится контекст с карточки в списке
загрузок.
Связано: [«Главная: список загрузок»](#главная-список-загрузок-вместо-таблицы),
[review-ux.md](specs/review-ux.md), [architecture.md](specs/architecture.md)
→ «Хранилище» (`download`/`recognition`/`file_link`), пакет `httpapi`.
### Машина состояний на go-библиотеке ### Машина состояний на go-библиотеке
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
@@ -139,16 +192,29 @@ qBittorrent с файлами), подтверждение осознаннос
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность), [jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность),
[workflow.md](specs/workflow.md) (повторный прогон загрузки). [workflow.md](specs/workflow.md) (повторный прогон загрузки).
### Улучшения UI клиентов: показывать матч с записью метабазы ### Улучшения UI: показывать матч с записью метабазы
Во всех транспортах (веб, Telegram) показывать, **с какой именно записью** Во всех транспортах (веб, Telegram) показывать, **с какой именно записью**
метабазы (TMDB/TVDB) сматчилась загрузка: название, год, провайдер-id, метабазы (TMDB/TVDB) сматчилась загрузка: название, год, провайдер-id,
ссылку. Сейчас результат распознавания непрозрачен — пользователь не видит, ссылку. Сейчас результат распознавания непрозрачен — пользователь не видит,
к чему привязались, и не может быстро поймать ошибочный матч. к чему привязались, и не может быстро поймать ошибочный матч. На web-стороне
развивается в полноценный выбор источника — см. [«Ревью: выбор источника
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр).
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md) Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
(матч в базе), [architecture.md](specs/architecture.md) → «Транспорты». (матч в базе), [architecture.md](specs/architecture.md) → «Транспорты».
### Аниме с абсолютной нумерацией
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
Jellyfin ждёт `SxxEyy`. Нужен пересчёт абсолютной нумерации в сезон/серию —
надёжнее всего через TVDB (там есть absolute order). Отдельный крайний
случай распознавания; на стороне ревью — веб-хелпер «absolute → S·E».
Связано: [recognition.md](specs/recognition.md) (конвейер, сезон-паки),
[jellyfin-layout.md](specs/jellyfin-layout.md) (нумерация серий),
[review-ux.md](specs/review-ux.md) (крайние сценарии).
### Добавление торрентов файлом/ссылкой — «единое окно» ### Добавление торрентов файлом/ссылкой — «единое окно»
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
@@ -204,24 +270,31 @@ qBittorrent, без исходящих запросов на пользоват
верификации, но дешевле: учимся на уже собранных `hint`/`override`. верификации, но дешевле: учимся на уже собранных `hint`/`override`.
Связано: [recognition.md](specs/recognition.md) (конвейер, промпт), Связано: [recognition.md](specs/recognition.md) (конвейер, промпт),
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-тема-для-размышления), [«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-идея),
[architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`). [architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`).
### Список загрузок: фильтр, поиск, пагинация ### Список загрузок: фильтр, поиск, пагинация
Прямое следствие роста БД (см. «Ретеншн»): плоский список загрузок со Прямое следствие роста БД (см. «Ретеншн»): плоский список загрузок со
временем становится непригоден. Нужны фильтр по состоянию, поиск по временем становится непригоден. Нужны фильтр по состоянию, поиск по
названию и пагинация. Естественно ложится на экран расширенной информации. названию и пагинация. Естественно ложится на список-карточки главной и на
экран расширенной информации.
Связано: [«Расширенная информация о загрузке в web-UI»](#расширенная-информация-о-загрузке-в-web-ui), Частный случай как разумный дефолт: на главной **по умолчанию скрывать
загрузки в статусе `deleted`** (терминальные, удалённые из источника и
цели — шум в ленте), с переключателем/фильтром «показать всё». Маленькая
часть, может приехать раньше полноценного фильтра.
Связано: [«Главная: список загрузок»](#главная-список-загрузок-вместо-таблицы),
[«Расширенная информация о загрузке в web-UI»](#расширенная-информация-о-загрузке-в-web-ui),
пакет `httpapi`. пакет `httpapi`.
## Низкий ## Низкий
### Многоступенчатая верификация привязки (тема для размышления) ### Многоступенчатая верификация привязки _(идея)_
Идея: несколько раз извлекать данные из раздачи и контекста разными Несколько раз извлекать данные из раздачи и контекста разными промптами,
промптами, искать в метабазах, затем сводить результаты в общий вердикт искать в метабазах, затем сводить результаты в общий вердикт
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и (голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
запросов к базам. Требует проработки: когда включать, как мерджить запросов к базам. Требует проработки: когда включать, как мерджить
расхождения, стоимость/латентность. расхождения, стоимость/латентность.
@@ -229,21 +302,14 @@ qBittorrent, без исходящих запросов на пользоват
Связано: [recognition.md](specs/recognition.md) (конвейер и модель Связано: [recognition.md](specs/recognition.md) (конвейер и модель
уверенности). уверенности).
### Расширенная информация о загрузке в web-UI
Экран просмотра деталей одной загрузки: исходный контекст и magnet, лог
переходов состояний, распознанные данные и матч в метабазе (см. «показывать
матч»), целевые пути и созданные хардлинки. Помогает разбираться, когда
что-то пошло не так, без чтения логов сервера.
Связано: [review-ux.md](specs/review-ux.md), пакет `httpapi`.
### Выбор из нескольких находок метабазы в Telegram ### Выбор из нескольких находок метабазы в Telegram
Когда распознавание даёт несколько подходящих кандидатов в метабазе, Когда распознавание даёт несколько подходящих кандидатов в метабазе,
предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча
брать первый/лучший. Веб остаётся точкой точных правок, бот — быстрый выбор брать первый/лучший. Веб остаётся точкой точных правок (полный выбор
из готового короткого списка. источника — см. [«Ревью: выбор источника
совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр)), бот —
быстрый выбор из готового короткого списка.
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб — Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
точные правки), [recognition.md](specs/recognition.md) (кандидаты матча). точные правки), [recognition.md](specs/recognition.md) (кандидаты матча).
@@ -268,6 +334,35 @@ qBittorrent, без исходящих запросов на пользоват
Связано: [recognition.md](specs/recognition.md) (сверка с базой), пакеты Связано: [recognition.md](specs/recognition.md) (сверка с базой), пакеты
`metadata`, `llm`. `metadata`, `llm`.
### guessit как сервис-спутник _(идея)_
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` в крошечный HTTP-сервис (один файл,
поставляется рядом с бинарём jellybit) и спрашивать его на шаге
пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
Связано: [recognition.md](specs/recognition.md) → «На будущее» (пред-парс).
### Завершение загрузки через webhook _(идея)_
Сейчас завершение ловим поллингом qBittorrent раз в несколько секунд.
Альтернатива: «Run external program on torrent completion» в qBittorrent
дёргает эндпоинт jellybit. Реагирует быстрее, но связывает нас с конфигом
qBittorrent. Решим по опыту эксплуатации.
Связано: [architecture.md](specs/architecture.md) → «Отслеживание загрузки»,
пакет `worker`.
### Авторизация веб-UI (на будущее)
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей
(`http.trusted_subnets`) — как умеет qBittorrent. Если понадобится защита:
токен/Basic в самом приложении или вынос за reverse-proxy с
аутентификацией.
Связано: [architecture.md](specs/architecture.md) → «Транспорты» (доступ к
веб-UI), пакет `httpapi`.
### Современный Web-UI как PWA ### Современный Web-UI как PWA
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
-42
View File
@@ -1,42 +0,0 @@
# Идеи и нерешённое
Свалка мыслей на будущее. Ни к чему не обязывает; принятое переезжает в
specs/adr.
## guessit как сервис-спутник
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` в крошечный HTTP-сервис (один файл,
поставляется рядом с бинарём jellybit) и спрашивать его на шаге
пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
## Аниме с абсолютной нумерацией
Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а
Jellyfin ждёт `SxxEyy`. Нужен пересчёт абсолютной нумерации в
сезон/серию — надёжнее всего через TVDB (там есть absolute order).
Отдельный крайний случай распознавания.
## Завершение загрузки через webhook
Сейчас принято — поллинг qBittorrent раз в несколько секунд.
Альтернатива: «Run external program on torrent completion» в qBittorrent
дёргает эндпоинт jellybit. Реагирует быстрее, но связывает нас с конфигом
qBittorrent. Решим по опыту эксплуатации.
## Нотификации о готовности
Когда раскладка завершена (или нужен review) — уведомить: Telegram,
возможно ntfy/Apprise. Естественно ложится на Telegram-транспорт.
## Доступ к веб-UI
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей
(`http.trusted_subnets`) — как умеет qBittorrent. На будущее, если
понадобится защита: токен/Basic в самом приложении или вынос за
reverse-proxy с аутентификацией.
## Повторный прогон распознавания
Возможность переоткрыть загрузку, поправить контекст и перераспознать без
перекачивания — полезно, когда LLM ошибся, а файлы уже скачаны.
+1 -1
View File
@@ -61,7 +61,7 @@ reject / defer / undo) — команды к `worker`:
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле (server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
`http.trusted_subnets` зарезервировано, но **пока не применяется**: `http.trusted_subnets` зарезервировано, но **пока не применяется**:
деплой только в локальную сеть без доступа из интернета, поэтому деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md). allowlist-middleware и авторизацию отложили — [backlog.md](../backlog.md#авторизация-веб-ui-на-будущее).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится - **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности. всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
+1 -1
View File
@@ -94,4 +94,4 @@ inode общий — диск не дублируется.
сезонам. сезонам.
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота. - **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка - **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
([drafts/ideas.md](../drafts/ideas.md)). ([backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)).
+2 -2
View File
@@ -129,10 +129,10 @@ notes пояснения, неоднозначности
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode; 00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
любая неоднозначность → review. любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай, см. - Аниме с абсолютной нумерацией — отдельный крайний случай, см.
[drafts/ideas.md](../drafts/ideas.md). [backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией).
## На будущее ## На будущее
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не `go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
бинарём). См. [drafts/ideas.md](../drafts/ideas.md). бинарём). См. [backlog.md](../backlog.md#guessit-как-сервис-спутник-идея).
+1 -1
View File
@@ -109,7 +109,7 @@ Telegram = одобрить / подсказать / выбрать кандид
provider-id и каноническое имя). provider-id и каноническое имя).
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с - **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
абсолютной нумерацией → веб-хелпер «absolute → S·E» (см. абсолютной нумерацией → веб-хелпер «absolute → S·E» (см.
[drafts/ideas.md](../drafts/ideas.md)). [backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)).
- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана. - **Не тот тип (movie↔series)** → переключатель пересобирает форму плана.
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор». - **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать - **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
+32 -7
View File
@@ -14,7 +14,7 @@ stateDiagram-v2
downloading --> completed: файлы на месте downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
completed --> recognizing completed --> recognizing
@@ -36,8 +36,10 @@ stateDiagram-v2
reverted --> recognizing: Привязать заново reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry stuck --> downloading: Retry / сверка (раздача ожила)
failed --> downloading: Retry failed --> downloading: Retry / сверка (метаданные пришли)
failed --> completed: сверка (торрент уже готов)
stuck --> completed: сверка (торрент уже готов)
done --> target_missing: сверка — цель удалена done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал done --> orphaned: сверка — источник пропал
@@ -147,10 +149,33 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
проверку (готовность не объявляем, даже если флаги «UP»). проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/ - **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`. `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше - **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck` `magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`.
(восстановимо ретраем). Возраст считаем от создания задачи. `magnet_timeout`**редкий страховочный предохранитель** (дефолт `24h`), а
- **ошибка:** `error`/`missingFiles``failed`. не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
переживает retry и усыновление).
- **ошибка:** `error`/`missingFiles``failed` (`error_code` `qbit_error`) —
это настоящий провал, в отличие от таймаута.
### Уведомление и восстановление
- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
(`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
(`stuck``downloading`) не спамил.
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
`stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
только источник в qBittorrent ожил и продвинулся за условие падения
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
REST): возвращает в `downloading`, перецепляясь к живому торренту без
повторного `Add`.
Пути файлов берём из API (`save_path` + относительные имена из Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из `/torrents/files`, уже включающие корневую папку торрента), не из
+1 -1
View File
@@ -181,7 +181,7 @@ func Default() *Config {
Worker: Worker{ Worker: Worker{
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(30 * time.Minute), MagnetTimeout: Duration(24 * time.Hour),
SourceMissingThreshold: 3, SourceMissingThreshold: 3,
}, },
Recognition: Recognition{AutoConfidenceThreshold: 0.85}, Recognition: Recognition{AutoConfidenceThreshold: 0.85},
+17 -1
View File
@@ -84,6 +84,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
// Веб-UI: ревью раскладки. // Веб-UI: ревью раскладки.
r.Get("/review/{id}", s.handleReview) r.Get("/review/{id}", s.handleReview)
@@ -133,6 +134,7 @@ type downloadView struct {
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted) Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
} }
@@ -182,6 +184,19 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
redirectErr(w, r, userErr(r, err, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- REST API --- // --- REST API ---
type downloadDTO struct { type downloadDTO struct {
@@ -320,7 +335,8 @@ func toView(d store.Download) downloadView {
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing, d.State == store.StateTargetMissing,
Note: desyncNote(d.State), Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Note: desyncNote(d.State),
} }
} }
+17
View File
@@ -160,6 +160,23 @@ func TestAPICancel(t *testing.T) {
} }
} }
func TestUIRetry(t *testing.T) {
cmd := &fakeCommander{}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/ui/downloads/5/retry", "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(cmd.retried) != 1 || cmd.retried[0] != 5 {
t.Errorf("retry вызван неверно: %v", cmd.retried)
}
}
func TestAPICommandConflict(t *testing.T) { func TestAPICommandConflict(t *testing.T) {
// Конфликт состояния (worker.ErrConflict) → 409, не 500. // Конфликт состояния (worker.ErrConflict) → 409, не 500.
cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)} cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
+18 -1
View File
@@ -18,6 +18,10 @@ import (
// capIngest — стадия приёма для поля capability в логах. // capIngest — стадия приёма для поля capability в логах.
const capIngest = "ingest" const capIngest = "ingest"
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища. // Store — нужная ingest часть хранилища.
type Store interface { type Store interface {
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error) FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
@@ -49,6 +53,12 @@ type Service struct {
namer Namer namer Namer
cfg Config cfg Config
log *slog.Logger log *slog.Logger
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID int64)
} }
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не // New собирает сервис приёма. namer опционален (nil → отображаемое имя не
@@ -57,6 +67,9 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log} return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
} }
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
// Request — входной запрос приёма. // Request — входной запрос приёма.
type Request struct { type Request struct {
Source string // пока — magnet-ссылка Source string // пока — magnet-ссылка
@@ -135,8 +148,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// это разные факты, не дубль. // это разные факты, не дубль.
log.Error("download accept failed", "error", addErr) log.Error("download accept failed", "error", addErr)
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил. // Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil { if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, errCodeQbitAdd, addErr.Error()); setErr != nil {
log.Error("mark download failed after qbit error failed", "error", setErr) log.Error("mark download failed after qbit error failed", "error", setErr)
} else if s.notifyFailed != nil {
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
} }
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed}, return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr) fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
+21
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"testing" "testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
@@ -177,6 +178,26 @@ func TestIngestQbitErrorMarksFailed(t *testing.T) {
} }
} }
func TestIngestQbitErrorNotifies(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
svc := newService(fs, fq)
got := make(chan int64, 1)
svc.SetFailureNotifier(func(id int64) { got <- id })
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
t.Fatal("ожидалась ошибка")
}
select {
case id := <-got:
if id == 0 {
t.Errorf("уведомление с нулевым id")
}
case <-time.After(2 * time.Second):
t.Fatal("уведомление о падении приёма не пришло")
}
}
func TestIngestRejectsNonMagnet(t *testing.T) { func TestIngestRejectsNonMagnet(t *testing.T) {
fs := &fakeStore{} fs := &fakeStore{}
fq := &fakeQbt{} fq := &fakeQbt{}
+31 -4
View File
@@ -40,10 +40,13 @@ const (
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key // ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
// снимается по IsTerminal, а активность считалась бы по другому списку). // снимается по IsTerminal, а активность считалась бы по другому списку).
// //
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по // Состояния рассинхрона (target_missing/orphaned/deleted), а также
// тем же причинам, что reverted/cancelled: дальше двигает либо человек // failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
// (relink из target_missing), либо фоновая сверка (healing/прогрессия), // двигает либо человек (relink из target_missing, retry из failed/stuck), либо
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен. // фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
// при оживлении источника, см. state-reconciliation) — напрямую через
// SetDownloadState, который восстановит ключ для нетерминального целевого
// состояния.
var terminalStates = []State{ var terminalStates = []State{
StateDone, StateCancelled, StateFailed, StateReverted, StateDone, StateCancelled, StateFailed, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted, StateTargetMissing, StateOrphaned, StateDeleted,
@@ -157,6 +160,30 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
return out, nil return out, nil
} }
// ListRecoverable возвращает задачи в failed/stuck с одним из переданных
// error_code — кандидатов на авто-восстановление (см. state-reconciliation).
// Фильтр по коду в SQL, чтобы не вычитывать на каждом тике поллинга все
// накопленные провалы (qbit_error и пр.), которые восстановлению не подлежат.
func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Download, error) {
if len(codes) == 0 {
return nil, nil
}
ph := make([]string, len(codes))
args := make([]any, 0, len(codes)+2)
for i, c := range codes {
ph[i] = "?"
args = append(args, c)
}
args = append(args, string(StateFailed), string(StateStuck))
q := `SELECT * FROM download WHERE error_code IN (` + strings.Join(ph, ",") +
`) AND state IN (?, ?) ORDER BY id DESC`
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list recoverable: %w", err)
}
return out, nil
}
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо // FindActiveByInfohash возвращает незавершённую задачу для infohash либо
// (nil, nil), если её нет. Основа идемпотентного приёма. // (nil, nil), если её нет. Основа идемпотентного приёма.
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) { func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
+6
View File
@@ -36,6 +36,7 @@ type Reviewer interface {
SetType(ctx context.Context, id int64, mediaType string) error SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
} }
// Config — параметры бота. // Config — параметры бота.
@@ -191,6 +192,9 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "reject": case "reject":
err = b.reviewer.Cancel(ctx, id) err = b.reviewer.Cancel(ctx, id)
note = "Отклонено" note = "Отклонено"
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "type": case "type":
err = b.reviewer.SetType(ctx, id, val) err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…" note = "Меняю тип…"
@@ -248,6 +252,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE
text = b.renderDone(rd) text = b.renderDone(rd)
case worker.EventTargetMissing, worker.EventOrphaned: case worker.EventTargetMissing, worker.EventOrphaned:
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID) text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
case worker.EventFailed:
text, kb = b.renderFailed(rd)
default: default:
text, kb = b.renderCard(rd) text, kb = b.renderCard(rd)
} }
+28
View File
@@ -65,6 +65,7 @@ type fakeReviewer struct {
typed map[int64]string typed map[int64]string
deferred []int64 deferred []int64
canceled []int64 canceled []int64
retried []int64
} }
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
@@ -96,6 +97,10 @@ func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
f.canceled = append(f.canceled, id) f.canceled = append(f.canceled, id)
return nil return nil
} }
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
f.retried = append(f.retried, id)
return nil
}
func reviewData(state store.State) *worker.ReviewData { func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1 s, e := 2, 1
@@ -252,6 +257,29 @@ func TestBot_NotifyDone(t *testing.T) {
} }
} }
func TestBot_NotifyFailed(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.Notify(context.Background(), 5, worker.EventFailed)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
t.Errorf("sent = %+v", api.sent)
}
if !api.sent[0].hasKB { // кнопка повтора
t.Error("уведомление о падении без клавиатуры повтора")
}
}
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
if len(rev.retried) != 1 || rev.retried[0] != 5 {
t.Errorf("retried = %v", rev.retried)
}
}
func TestParseCallback(t *testing.T) { func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series") a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" { if a != "type" || id != 5 || v != "series" {
+39
View File
@@ -31,6 +31,10 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
if msg := rd.Download.ErrorMsg.String; msg != "" { if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg text += "\n" + msg
} }
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
return text, b.retryKeyboard(id)
}
return text, b.webOnly(id) return text, b.webOnly(id)
} }
} }
@@ -111,6 +115,41 @@ func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) stri
} }
} }
// renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора.
func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
id := rd.Download.ID
var sb strings.Builder
verb := "не удалась"
if rd.Download.State == store.StateStuck {
verb = "зависла"
}
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
if code := rd.Download.ErrorCode.String; code != "" {
fmt.Fprintf(&sb, " (%s)", code)
}
sb.WriteString(".")
if msg := rd.Download.ErrorMsg.String; msg != "" {
sb.WriteString("\n")
sb.WriteString(msg)
}
if src := contextOrSource(rd); src != "" {
fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80))
}
return sb.String(), b.retryKeyboard(id)
}
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup { func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id) url := b.reviewURL(id)
if url == "" { if url == "" {
+90
View File
@@ -160,6 +160,96 @@ func reconcileReason(sourcePresent, targetPresent bool) string {
} }
} }
// --- Восстановление зависших загрузок (failed/stuck → поток) ---
// reconcileRecovery воскрешает задачи, упавшие из-за нашей нетерпеливости
// (magnet_timeout/stalled), когда их источник в qBittorrent ожил и продвинулся
// за условие падения. Вызывается из Poll под w.mu. Реальные/пользовательские
// провалы (qbit_error/reverted/cancelled/deleted) сюда не попадают.
func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.Torrent) {
cands, err := w.store.ListRecoverable(ctx, errCodeMagnetTimeout, errCodeStalled)
if err != nil {
w.log.Warn("recovery list failed", "capability", capIngest, "error", err)
return
}
for _, d := range cands {
w.reconcileOneRecovery(ctx, d, byHash)
}
}
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
// присутствует и продвинулся за условие падения.
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
return
}
t, ok := byHash[strings.ToLower(d.Infohash.String)]
if !ok {
return // источника нет — оставляем как есть (вернёт ручной retry)
}
if !torrentProgressed(d, t) {
return // торрент всё ещё в metaDL/stalledDL или ошибочен — не воскрешаем
}
want := recoveredState(t.State)
if want == "" {
return // переходное состояние qBit (moving/checking) — ждём
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
// взять другая активная задача (idempotency_key снят при падении). Оба
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
// и оставляем старую задачу в failed.
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
return
}
if other != nil && other.ID != d.ID {
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
"conflict_download_id", other.ID)
return
}
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
w.transition(ctx, d, want, "", "")
}
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
// задача упала: для magnet_timeout — получил метаданные (вышел из metaDL); для
// stalled — раздача ожила (вышла из stalledDL). Ошибочные состояния qBittorrent
// продвижением не считаем (их ведёт обычный reconcile в qbit_error).
func torrentProgressed(d store.Download, t qbt.Torrent) bool {
if classify(t.State) == classErrored {
return false
}
switch d.ErrorCode.String {
case errCodeMagnetTimeout:
return !isMeta(t.State)
case errCodeStalled:
return !isStalledDL(t.State)
default:
return false
}
}
// recoveredState выводит состояние воскрешённой задачи из состояния торрента:
// готов к раскладке → completed; ещё качается → downloading. Переходные
// (moving/checking) и ошибочные состояния не восстанавливаем (пусто).
func recoveredState(state string) store.State {
switch classify(state) {
case classReady:
return store.StateCompleted
case classDownloading:
return store.StateDownloading
default:
return ""
}
}
// --- Синхронный preflight перед действием (не доверяем state в БД) --- // --- Синхронный preflight перед действием (не доверяем state в БД) ---
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в // ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
+170
View File
@@ -0,0 +1,170 @@
package worker
import (
"context"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// addedRecent — added_on торрента «минуту назад» относительно зафиксированного
// в newTestWorker now (2026-06-14 10:00:00 UTC).
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
State: state,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
ErrorCode: store.NullString(code),
CreatedAt: createdAt,
},
}}
}
func TestRecovery(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
tests := []struct {
name string
state store.State
code string
qbitState string
want store.State
}{
{"метаданные пришли → downloading", store.StateFailed, errCodeMagnetTimeout, "downloading", store.StateDownloading},
{"торрент готов → completed", store.StateFailed, errCodeMagnetTimeout, "uploading", store.StateCompleted},
{"всё ещё metaDL → остаётся failed", store.StateFailed, errCodeMagnetTimeout, "metaDL", store.StateFailed},
{"stalled ожил → downloading", store.StateStuck, errCodeStalled, "downloading", store.StateDownloading},
{"stalled всё ещё stalledDL → остаётся stuck", store.StateStuck, errCodeStalled, "stalledDL", store.StateStuck},
{"qbit_error не восстанавливается", store.StateFailed, errCodeQbitError, "downloading", store.StateFailed},
{"ошибка торрента не восстанавливает", store.StateFailed, errCodeMagnetTimeout, "error", store.StateFailed},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
st := oneFailed(tc.state, tc.code, ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState, AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
}
}
// Источник пропал (торрента нет в qBittorrent) — задача остаётся failed,
// воскрешать нечего (вернёт ручной retry).
func TestRecoveryNoSourceStaysFailed(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
w := newTestWorker(st, &fakeQbt{torrents: nil})
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
}
}
// Конфликт идемпотентности: тот же infohash уже взяла другая активная задача —
// упавшую не воскрешаем (иначе нарушим «одна активная задача на infohash»).
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
}
}
// Тот же конфликт ключа, но торрент уже готов (recovery хочет completed):
// completed тоже нетерминален и восстановил бы idempotency_key — проверка
// конфликта обязана покрывать и эту ветку.
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
}
}
// Повторное падение одной задачи в пределах окна дебаунса шлёт уведомление лишь
// раз (защита от спама при флаппинге stuck↔downloading).
func TestFailNotifyDebounce(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
w := newTestWorker(st, &fakeQbt{})
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
d := *st.downloads[1]
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
if e := waitNotify(t, n); e.ev != EventFailed {
t.Fatalf("первый пинг: ev=%v, want failed", e.ev)
}
// Второе падение при том же w.now() — в пределах дебаунса, без пинга.
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
select {
case e := <-n.ch:
t.Fatalf("повторный пинг в пределах дебаунса не ожидался: %+v", e)
case <-time.After(200 * time.Millisecond):
}
}
// Retry при живом торренте перецепляется к нему (без повторного Add) и не падает
// снова на ближайшем тике: базис таймаута берётся от added_on, а не от старого
// created_at.
func TestRetryReattachesNoReadd(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
// Торрент жив, всё ещё тянет метаданные, но добавлен только что (added_on).
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
}
if len(qb.added) != 0 {
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
}
// Ближайший тик: metaDL свежий (added_on минуту назад) — не падает по таймауту.
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
}
}
+16
View File
@@ -250,6 +250,22 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State
return out, nil return out, nil
} }
func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
var out []store.Download
for _, d := range m.downloads {
if d.State != store.StateFailed && d.State != store.StateStuck {
continue
}
for _, c := range codes {
if d.ErrorCode.Valid && d.ErrorCode.String == c {
out = append(out, *d)
break
}
}
}
return out, nil
}
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) { func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
for _, d := range m.downloads { for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash { if d.Infohash.Valid && d.Infohash.String == infohash {
+98 -20
View File
@@ -38,6 +38,7 @@ const (
// Store — нужная worker часть хранилища. // Store — нужная worker часть хранилища.
type Store interface { type Store interface {
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error) ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
SetSourceMissCount(ctx context.Context, id int64, n int) error SetSourceMissCount(ctx context.Context, id int64, n int) error
@@ -94,6 +95,17 @@ const (
EventDone NotifyEvent = "done" // раскладка завершена EventDone NotifyEvent = "done" // раскладка завершена
EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия
EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink
EventFailed NotifyEvent = "failed" // задача упала/зависла (failed/stuck)
)
// Коды ошибок (error_code) при переходе в failed/stuck. Восстановимые
// (magnet_timeout/stalled) — следствие нашей нетерпеливости: сверка воскрешает
// такие задачи при оживлении источника (см. reconcileRecovery). qbit_error —
// реальная ошибка qBittorrent, восстановлению не подлежит.
const (
errCodeMagnetTimeout = "magnet_timeout"
errCodeStalled = "stalled"
errCodeQbitError = "qbit_error"
) )
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
@@ -136,8 +148,18 @@ type Worker struct {
newID func() string // генератор apply_batch_id (подменяется в тестах) newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin scanner Scanner // опц. пересканирование Jellyfin
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
// время последнего пинга). Мерцающий stalled-торрент колеблется
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
failNotified map[int64]time.Time
} }
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
// одной задачи (см. failNotified).
const failNotifyDebounce = time.Hour
// SetNotifier подключает исходящие пинги (до запуска Run). // SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
@@ -148,14 +170,15 @@ func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. // распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
return &Worker{ return &Worker{
store: st, store: st,
qbt: qb, qbt: qb,
recognizer: rec, recognizer: rec,
layouter: lay, layouter: lay,
cfg: cfg, cfg: cfg,
log: log, log: log,
now: time.Now, now: time.Now,
newID: defaultBatchID, newID: defaultBatchID,
failNotified: map[int64]time.Time{},
} }
} }
@@ -246,6 +269,10 @@ func (w *Worker) Poll(ctx context.Context) error {
// Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС) // Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС)
// — отдельно от активных, по двумерной матрице (см. state-reconciliation). // — отдельно от активных, по двумерной матрице (см. state-reconciliation).
w.reconcileDesync(ctx, byHash) w.reconcileDesync(ctx, byHash)
// Восстановление задач, упавших по нашей нетерпеливости (magnet_timeout/
// stalled), если их источник в qBittorrent ожил и продвинулся.
w.reconcileRecovery(ctx, byHash)
return nil return nil
} }
@@ -257,7 +284,7 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
case classReady: case classReady:
w.transition(ctx, d, store.StateCompleted, "", "") w.transition(ctx, d, store.StateCompleted, "", "")
case classErrored: case classErrored:
w.transition(ctx, d, store.StateFailed, "qbit_error", "qBittorrent state: "+t.State) w.transition(ctx, d, store.StateFailed, errCodeQbitError, "qBittorrent state: "+t.State)
case classDownloading: case classDownloading:
w.checkTimeouts(ctx, d, t) w.checkTimeouts(ctx, d, t)
case classBusy: case classBusy:
@@ -265,26 +292,43 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
} }
} }
// checkTimeouts помечает зависшие задачи. Возраст считаем от created_at: // checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
// для metaDL это время с момента добавления (огрублённо, но достаточно). // qBittorrent (added_on), а не от created_at: базис переживает retry и
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) { func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
created, err := d.CreatedTime() age := w.torrentAge(d, t)
if err != nil {
logctx.From(ctx).Warn("cannot parse created_at", "value", d.CreatedAt, "error", err)
return
}
age := w.now().Sub(created)
switch { switch {
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout: case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
w.transition(ctx, d, store.StateFailed, "magnet_timeout", w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second))) fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter: case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
w.transition(ctx, d, store.StateStuck, "stalled", w.transition(ctx, d, store.StateStuck, errCodeStalled,
fmt.Sprintf("stalled for %s", age.Truncate(time.Second))) fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
} }
} }
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
if t.AddedOn > 0 {
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
}
created, err := d.CreatedTime()
if err != nil {
// Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен,
// таймауты не сработают; фиксируем диагностикой.
w.log.Warn("cannot determine torrent age",
"capability", capIngest, "download_id", d.ID,
"created_at", d.CreatedAt, "error", err)
return 0
}
return w.now().Sub(created)
}
// transition пишет новое состояние и логирует переход. // transition пишет новое состояние и логирует переход.
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) { func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на // FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
@@ -308,6 +352,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
go w.notifier.Notify(context.Background(), d.ID, EventOrphaned) go w.notifier.Notify(context.Background(), d.ID, EventOrphaned)
case store.StateTargetMissing: case store.StateTargetMissing:
go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing) go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing)
case store.StateFailed, store.StateStuck:
if w.shouldNotifyFail(d.ID) {
go w.notifier.Notify(context.Background(), d.ID, EventFailed)
}
} }
} }
@@ -323,6 +371,25 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
} }
} }
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
// флаппинг.
func (w *Worker) shouldNotifyFail(id int64) bool {
now := w.now()
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
return false
}
w.failNotified[id] = now
// Лёгкая чистка устаревших записей, чтобы карта не росла без предела.
for k, t := range w.failNotified {
if now.Sub(t) >= failNotifyDebounce {
delete(w.failNotified, k)
}
}
return true
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен). // раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id int64) error { func (w *Worker) Cancel(ctx context.Context, id int64) error {
@@ -356,7 +423,18 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
if d.State != store.StateFailed && d.State != store.StateStuck { if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State) return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
} }
if d.SourceType == store.SourceMagnet { // Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
if d.Infohash.Valid {
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
}
if !alive && d.SourceType == store.SourceMagnet {
if err := w.qbt.Add(ctx, qbt.AddRequest{ if err := w.qbt.Add(ctx, qbt.AddRequest{
URLs: []string{d.SourceRef}, URLs: []string{d.SourceRef},
Category: w.cfg.Category, Category: w.cfg.Category,
+16
View File
@@ -42,6 +42,22 @@ func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.Stat
return out, nil return out, nil
} }
func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
var out []store.Download
for _, d := range f.downloads {
if d.State != store.StateFailed && d.State != store.StateStuck {
continue
}
for _, c := range codes {
if d.ErrorCode.Valid && d.ErrorCode.String == c {
out = append(out, *d)
break
}
}
}
return out, nil
}
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-30
@@ -0,0 +1,158 @@
## Context
Машина состояний загрузок живёт в `internal/worker/worker.go`
(`Poll`/`reconcile`/`checkTimeouts`/`transition`/`Retry`), список состояний и
терминальность — в `internal/store/download.go`. Граф переходов описан в
`docs/specs/workflow.md` (ещё не мигрирован в OpenSpec — он остаётся
источником истины по жизненному циклу). Сверка реальности с БД (capability
`state-reconciliation`) реализована в `reconcileDesync` и уже исключает
`failed`/`stuck`.
Текущее поведение, породившее инцидент:
- `checkTimeouts` (worker.go:268-285) меряет возраст задачи от `created_at` и
при `metaDL` дольше `magnet_timeout` гонит в `failed`/`magnet_timeout`.
Дефолт `magnet_timeout` = 30m (`config.go:184`), но для долгих magnet это
слишком агрессивно.
- `failed`/`stuck` терминальны, выхода нет; `transition` уведомляет только
`review`/`done`/`orphaned`/`target_missing` (worker.go:301-312) — падение
молчит.
- `Worker.Retry` (worker.go:348-373) возвращает в `downloading`, но базис
таймаута (`created_at`) не меняется → `checkTimeouts` роняет задачу снова
на ближайшем тике; retry экспонирован только в REST.
`qbt.Torrent` уже содержит `AddedOn` (unix, секунды) — время добавления
торрента в qBittorrent.
## Goals / Non-Goals
**Goals:**
- Долгий `metaDL` не убивается агрессивно; `magnet_timeout` — редкий
страховочный предохранитель (дефолт 24h), а не рабочий механизм.
- Корректный базис таймаута — от факта в qBittorrent (`added_on`), не от
`created_at`.
- Любой переход в `failed`/`stuck` уведомляет автора.
- Авто-восстановление задач, упавших по нашей нетерпеливости
(`magnet_timeout`/`stalled`), когда источник в qBittorrent ожил и
продвинулся.
- Ручной retry в веб-UI и Telegram; перецепление к живому торренту вместо
слепого повторного `Add`.
**Non-Goals:**
- Не воскрешаем реальные/пользовательские провалы: `qbit_error`, `reverted`,
`cancelled`, `deleted`.
- Не трогаем сам торрент в qBittorrent при падении (источник
неприкосновенен).
- Без миграций БД и без новых внешних зависимостей.
- Не вводим отдельную capability `notifications` — преждевременно.
## Decisions
### 1. Базис таймаута — `added_on`, а не `created_at`
`checkTimeouts` считает `age = now - torrent.AddedOn` (UTC). Это чинит
неверный отсчёт для усыновлённых раздач и — главное — делает retry/восстановление
устойчивым: после возврата в `downloading` базис не сбрасывается в «сейчас»,
он привязан к реальному возрасту торрента. Отдельный сброс `created_at` при
Retry больше не нужен.
*Альтернатива:* хранить «время входа в metaDL» отдельным полем БД — точнее,
но требует миграции и записи на каждый тик. `added_on` достаточно (огрубление
в большую сторону безопасно при 24h-предохранителе).
### 2. Дефолт `magnet_timeout` → 24h
Меняем дефолт в `config.go` и `config.example.toml`. Реальные провалы ловятся
классом `classErrored` (`error`/`missingFiles``qbit_error`) — это уже
работает и не зависит от wall-clock. У qBittorrent нет статуса «magnet мёртв»,
поэтому большой страховочный таймаут — единственный сигнал на безнадёжный
magnet.
### 3. Уведомление о падении
В `transition` добавляем ветки для `StateFailed` и `StateStuck` → новый
`EventFailed`. Сообщение в `notifier` (tgbot/httpapi) читает состояние и
`error_code` задачи и формирует текст. Один `Event` на оба состояния —
дробить на `EventStuck` смысла нет (различие видно из `error_code`).
### 4. Авто-восстановление в сверке
Отдельный проход `reconcileRecovery` (рядом с `reconcileDesync`, под `w.mu`,
из `Poll`): берём задачи в `failed`/`stuck` с восстановимым `error_code`
(`magnet_timeout`/`stalled`), находим их торрент в уже построенном индексе
`byHash`. Воскрешаем **только если торрент продвинулся за условие падения**:
- `magnet_timeout`: восстанавливаем, когда `!isMeta(state)` и не `classErrored`
(метаданные получены);
- `stalled`: восстанавливаем, когда `!isStalledDL(state)` и не `classErrored`
(раздача ожила).
Иначе (торрент всё ещё в `metaDL`/`stalledDL`, либо отсутствует) — оставляем
как есть. Это **критично против зацикливания**: при 24h-предохранителе мёртвый
magnet, упавший по таймауту, остаётся в `metaDL`; без проверки прогресса
восстановление вернуло бы его в `downloading`, и он падал бы снова каждые 24ч.
Целевое состояние выводим из `classify(state)`: `classReady``completed`,
`classDownloading``downloading`. При возврате в `downloading`
восстанавливаем `idempotency_key = infohash` (нужен метод стора, т.к.
`SetDownloadState` его при терминальном переходе снимает), чтобы повторный
приём снова дедуплицировался.
*Альтернатива:* расширить `reconcileDesync` матрицей «источник × цель». Не
подходит: у `failed`/`stuck` нет разложенной цели, ось другая (прогресс
источника), отдельный проход чище.
### 5. Починка `Worker.Retry` + кнопки в UI/Telegram
`Retry`: если торрент задачи уже есть в qBittorrent (живой) — не делаем
повторный `Add`, только переводим в `downloading` и восстанавливаем
`idempotency_key`; `Add` выполняем, только когда раздачи нет. Базис таймаута
теперь `added_on`, поэтому немедленного повторного падения нет (корень бага
устранён решением 1). В `internal/httpapi` (веб-UI) и `internal/tgbot`
добавляем действие retry рядом с существующим Cancel, вызывающее тот же
`Worker.Retry`.
### 6. `error_code` в именованные константы
Строки `"magnet_timeout"`, `"stalled"`, `"qbit_error"`, `"qbit_add"` выносим в
именованные константы (рядом с состояниями в `store` или в `worker`), чтобы
проверка восстановимости (`magnet_timeout`/`stalled`) и присвоение не
расходились по литералам. Требует, чтобы `error_code` задачи был доступен из
`store.Download` (проверить наличие поля; при отсутствии — добавить чтение,
без миграции, столбец уже есть).
## Risks / Trade-offs
- **Флаппинг уведомлений** (fail → восстановление → fail) → при дефолте 24h и
условии «торрент продвинулся» падение и воскрешение редки; повторный fail
возможен только если раздача снова реально застрянет. Доп. дебаунс не
вводим — усложнение без явной нужды.
- **Базис `added_on` огрубляет** (re-add торрента сбрасывает возраст) → при
24h-предохранителе и авто-восстановлении это не приводит к ложным провалам;
ранее проблема была в 30m-агрессии, которую и убираем.
- **`magnet_timeout` всё ещё может ложно сработать** на очень медленном, но
живом magnet (>24h до метаданных) → теперь это не тупик: уведомление + при
получении метаданных авто-восстановление вернёт задачу, плюс есть ручной
retry.
- **`idempotency_key` восстановление** при воскрешении: если за время в
`failed` пользователь успел повторно принять тот же infohash и завести
новую задачу, ключ уже занят. Обрабатываем как конфликт (не воскрешаем
старую либо логируем и оставляем в failed) — уточнить в реализации.
## Migration Plan
Изменение поведения + конфига, без миграций БД и без слома API. Деплой —
обычный (новый бинарь на umbar). Дефолт `magnet_timeout` меняется; явное
значение в существующем `config.toml` сохраняет поведение пользователя.
Откат — предыдущий бинарь; данные совместимы.
## Open Questions
Решены на ревью дизайна:
- **Занятый `idempotency_key` при авто-восстановлении** → старую задачу не
воскрешаем, оставляем в `failed` и логируем конфликт.
- **Уведомление об успешном авто-восстановлении** → не шлём, достаточно
лога.
@@ -0,0 +1,75 @@
## Why
Загрузка magnet'ом ушла в терминальный `failed`/`magnet_timeout` по
wall-clock таймауту (возраст от `created_at`, ~1ч), хотя qBittorrent просто
долго тянул метаданные (медленные трекеры / мало пиров). Метаданные в итоге
пришли, торрент жив и качается, но задача застряла в терминальном состоянии
без выхода — восстановить её нельзя. Вдобавок падение происходит молча (нет
уведомления автору), а единственный путь возврата `Worker.Retry` баговый
(не сбрасывает базис времени → задача мгновенно снова падает) и доступен
только через REST, но не из веб-UI и Telegram.
## What Changes
- **Терпеливость к `metaDL`.** Перестаём убивать долгий magnet агрессивным
таймаутом. Дефолт `[worker].magnet_timeout` поднимается до `24h` — это
редкий страховочный предохранитель, а не рабочий механизм. Настоящие
провалы определяются по статусам ошибок qBittorrent (`error`/`missingFiles`
`qbit_error`), а не по wall-clock. *(У qBittorrent нет статуса «magnet
мёртв» — зависший magnet вечно висит в `metaDL`, поэтому единственный
сигнал на этот кейс — большой страховочный таймаут.)*
- **Базис таймаута — от факта, а не от `created_at`.** Возраст для
`magnet_timeout`/`stalled` считаем от времени добавления торрента в
qBittorrent (`added_on`), а не от создания записи. Это чинит неверный
отсчёт для усыновлённых раздач и устраняет мгновенное повторное падение
после возврата в `downloading`.
- **Уведомление о любом падении.** Переход в `failed` (любой `error_code`)
и `stuck` уведомляет автора загрузки через `notifier` (раньше уведомления
слались только для `review`/`done`/`orphaned`/`target_missing`).
- **Авто-восстановление из `failed`/`stuck`.** Фоновая сверка
(state-reconciliation) замечает, что у задачи в восстановимом
`failed`/`stuck` (наша нетерпеливость: `magnet_timeout`, `stalled`)
источник в qBittorrent жив и продвинулся, и возвращает задачу в поток
(`downloading` либо `completed` по статусу торрента). Пользовательские
и реальные провалы (`qbit_error`, `reverted`, `cancelled`) сверка не
воскрешает.
- **Ручной retry из UI и Telegram.** Кнопка повторной попытки добавляется в
веб-UI и Telegram-бот (раньше — только Cancel; retry был только в REST).
`Worker.Retry` чинится: перецепляется к уже живому торренту вместо слепого
повторного `Add`, базис таймаута сбрасывается.
## Capabilities
### New Capabilities
Нет. Уведомления о падении и семантика таймаута относятся к жизненному циклу
загрузки, который пока живёт в `docs/specs/workflow.md` (ещё не мигрирован в
OpenSpec); заводить отдельную capability `notifications` сейчас —
преждевременное дробление.
### Modified Capabilities
- `state-reconciliation`: восстановимые `failed`/`stuck` (`magnet_timeout`,
`stalled`) перестают быть «неприкосновенными» для сверки и подлежат
авто-восстановлению при живом продвинувшемся источнике; добавляется
требование о восстановлении и о доступности ручного retry. `qbit_error`,
`reverted`, `cancelled`, `deleted` остаются вне восстановления.
## Impact
- **Спеки:** дельта `state-reconciliation`; обновление графа переходов и
семантики таймаута/уведомлений в `docs/specs/workflow.md` (источник истины
по жизненному циклу до миграции).
- **Конфиг:** дефолт `[worker].magnet_timeout``24h`
(`internal/config/config.go`), `config.example.toml`.
- **Код:** `internal/worker/worker.go``transition` (уведомление о
failed/stuck), `checkTimeouts` (базис от `added_on`), `reconcile`/
`reconcileDesync` (воскрешение из failed/stuck), `Retry` (перецепление +
сброс базиса); новый `Event` падения и его обработка в `notifier`/
`tgbot`/`httpapi`; кнопка retry в `internal/httpapi` и `internal/tgbot`;
вынос строки `"magnet_timeout"` (и смежных `error_code`) в именованные
константы рядом с состояниями.
- **qBittorrent-клиент:** возможно потребуется поле `added_on` в
`qbt.Torrent` (если ещё не читается).
- **Миграции БД:** не ожидаются (восстановление опирается на состояние qBit и
существующие поля задачи).
@@ -0,0 +1,141 @@
## MODIFIED Requirements
### Requirement: Периодическая сверка состояния с реальностью
`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача с `download.infohash` в выдаче qBittorrent) и
присутствия **цели** (см. требование о владении целевым путём: существуют все
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке).
Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).
#### Scenario: Источник и цель на месте — состояние не меняется
- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются
#### Scenario: Частичная пропажа цели считается отсутствием
- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`
#### Scenario: Задача в deleted сверкой не переоценивается
- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
## ADDED Requirements
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
При возврате в любое нетерминальное состояние (`downloading` или
`completed`) система SHALL восстанавливать идемпотентность задачи
(`idempotency_key`), чтобы повторный приём того же infohash снова
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
же infohash уже завладела другая активная задача (ключ снимается при падении и
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** её `idempotency_key` восстанавливается
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
`created_at`).
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
существующему торренту, а не добавлять источник повторно вслепую; повторный
`Add` выполняется, только когда раздачи в qBittorrent нет.
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту
#### Scenario: Retry доступен в Telegram
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
Telegram-боте
- **THEN** задача возвращается в `downloading`
#### Scenario: Retry без живого источника добавляет торрент заново
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник (magnet) добавляется в qBittorrent заново
- **AND** задача переходит в `downloading`
@@ -0,0 +1,76 @@
## 1. Константы и базис таймаута
- [x] 1.1 Вынести `error_code`-строки (`magnet_timeout`, `stalled`,
`qbit_error` — в `worker`; `qbit_add` — в `ingest`) в именованные константы;
`error_code` уже читается из `store.Download.ErrorCode` (миграция не нужна)
- [x] 1.2 В `checkTimeouts` считать возраст от `torrent.AddedOn` (UTC) через
хелпер `torrentAge` (фолбэк на `created_at`, если `added_on` нет)
- [x] 1.3 Поднять дефолт `[worker].magnet_timeout` до `24h` в
`internal/config/config.go` и `config.example.toml` (с пометкой про страховку)
## 2. Уведомление о падении
- [x] 2.1 Добавить `EventFailed` в `NotifyEvent` (worker.go)
- [x] 2.2 В `transition` слать `EventFailed` при переходе в `StateFailed` и
`StateStuck` (неблокирующе, вне `w.mu`)
- [x] 2.3 Обработать `EventFailed` в Telegram (`renderFailed` + `retryKeyboard`);
httpapi `Notifier` не реализует — только tgbot
## 3. Авто-восстановление в сверке
- [x] 3.1 Восстановление `idempotency_key` отдельным методом стора НЕ нужно:
`SetDownloadState` сам ставит ключ в `infohash` для нетерминального состояния
- [x] 3.2 Реализовать `reconcileRecovery` (под `w.mu`, из `Poll`): задачи в
`failed`/`stuck` с `error_code` `magnet_timeout`/`stalled`, поиск в `byHash`
- [x] 3.3 Воскрешать только при прогрессе торрента (`torrentProgressed`):
`magnet_timeout``!isMeta`; `stalled``!isStalledDL`; не `classErrored`;
целевое состояние из `classify` (`recoveredState`: ready → `completed`,
downloading → `downloading`)
- [x] 3.4 Конфликт занятого `idempotency_key`: пре-проверка через
`FindActiveByInfohash` — оставляем в `failed` + лог
- [x] 3.5 Тесты (`recovery_test.go`): метаданные → `downloading`; готов →
`completed`; всё ещё `metaDL``failed`; `qbit_error`/ошибка не воскрешаются;
нет источника → `failed`; конфликт ключа → `failed`
## 4. Починка Retry и ручной retry в транспортах
- [x] 4.1 `Worker.Retry`: при живом торренте — без повторного `Add`, только
`downloading`; `Add` лишь когда раздачи нет
- [x] 4.2 Тест: retry при живом торренте не делает `Add` и не падает на
ближайшем тике (базис `added_on`)
- [x] 4.3 Кнопка retry в веб-UI (`internal/httpapi` + `index.html`) для
`failed`/`stuck`; роут `/ui/downloads/{id}/retry`; тест `TestUIRetry`
- [x] 4.4 Действие retry в Telegram-боте (колбэк `retry:` + кнопка); тесты
`TestBot_CallbackRetry`, `TestBot_NotifyFailed`
## 5. Документация спек
- [x] 5.1 Обновить `docs/specs/workflow.md`: `magnet_timeout` как страховка,
базис `added_on`, уведомление о `failed`/`stuck`, восстановление и retry
- [x] 5.2 `openspec validate --strict download-failure-recovery` — без ошибок
## 6. Проверка
- [x] 6.1 `task lint` (0 issues) и `task test` (зелёные)
- [x] 6.2 Ревью кода (чекпоинт до archive) — 8-угловой multi-agent проход
## 7. Фиксы по ревью кода
- [x] 7.1 Конфликт `idempotency_key`: проверка `FindActiveByInfohash`
распространена на ветку `completed` (а не только `downloading`) —
иначе constraint-ошибка и зависание в `failed` с логом каждый тик
(тест `TestRecoverySkipsConflictOnCompleted`)
- [x] 7.2 Recovery не пишет в `error_msg` здоровой задачи (передаём `""`,
причину — в лог), иначе заметка светилась бы как ошибка в UI/REST
- [x] 7.3 Эффективность: `reconcileRecovery` грузит кандидатов через
`store.ListRecoverable` (SQL-фильтр по `error_code`), а не вычитывает все
failed/stuck каждый тик
- [x] 7.4 Дебаунс уведомлений о падении (`shouldNotifyFail`, окно 1h) —
мерцающий stalled-торрент не спамит `EventFailed` (тест
`TestFailNotifyDebounce`)
- [x] 7.5 Уведомление о падении `qbit_add` в ingest через closure
(`SetFailureNotifier`, wiring в serve.go), тест
`TestIngestQbitErrorNotifies`
- [x] 7.6 Конвенция логирования: убран неймспейс-префикс `recovery:` из `msg`
- [x] 7.7 Мелочи: дедуп ветки `renderCard`, диагностика в `torrentAge`,
`time.Unix(...).UTC()`, актуализирован комментарий `terminalStates`
+106 -5
View File
@@ -22,11 +22,17 @@ qBittorrent. Capability описывает периодическую и при
ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой ссылки последнего батча со статусом раскладки, всё ещё принадлежащие этой
загрузке). загрузке).
Сверке SHALL подвергаться только состояния `done`, `target_missing`, Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`orphaned`. Состояние `deleted` сверка трогать SHALL NOT — оно терминально. `target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
Активные (`downloading`/`recognizing`/`review`/`deferred`/`linking`) и оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
пользовательски-терминальные (`reverted`/`cancelled`/`failed`/`stuck`) `linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
состояния сверка трогать SHALL NOT. сверка по матрице трогать SHALL NOT.
**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.
Состояние SHALL переписываться только при его изменении (без записи и логов, Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим). когда выведенное состояние совпадает с текущим).
@@ -49,6 +55,101 @@ qBittorrent. Capability описывает периодическую и при
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её - **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
бывшему пути появился файл другой загрузки бывшему пути появился файл другой загрузки
#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит
- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет
### Requirement: Восстановление зависшей загрузки при оживлении источника
Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.
При возврате в любое нетерминальное состояние (`downloading` или
`completed`) система SHALL восстанавливать идемпотентность задачи
(`idempotency_key`), чтобы повторный приём того же infohash снова
дедуплицировался на эту задачу. Если за время простоя в `failed`/`stuck` тем
же infohash уже завладела другая активная задача (ключ снимается при падении и
мог быть перехвачен новым приёмом), система SHALL NOT воскрешать упавшую
задачу и SHALL оставить её в `failed`/`stuck`, сохраняя инвариант «не более
одной активной задачи на infohash».
`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом: пока торрент в `metaDL`/`forcedMetaDL` или иным образом
прогрессирует в пределах страховочного таймаута, задача в `failed`/`stuck`
из-за него оказаться SHALL NOT (см. требование о терпеливости к долгим
метаданным в `docs/specs/workflow.md`).
#### Scenario: Метаданные пришли после magnet_timeout
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** её `idempotency_key` восстанавливается
#### Scenario: Торрент уже завершился, пока задача была в failed
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
(распознавание/раскладка)
#### Scenario: Источник так и не ожил — состояние не меняется
- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`
#### Scenario: infohash уже занят другой активной задачей
- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
`created_at`).
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
существующему торренту, а не добавлять источник повторно вслепую; повторный
`Add` выполняется, только когда раздачи в qBittorrent нет.
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту
#### Scenario: Retry доступен в Telegram
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
Telegram-боте
- **THEN** задача возвращается в `downloading`
#### Scenario: Retry без живого источника добавляет торрент заново
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник (magnet) добавляется в qBittorrent заново
- **AND** задача переходит в `downloading`
### Requirement: Принудительная проверка источника/цели перед действием ### Requirement: Принудительная проверка источника/цели перед действием
Команда workflow, требующая наличия источника или цели, SHALL синхронно Команда workflow, требующая наличия источника или цели, SHALL синхронно
+5
View File
@@ -78,6 +78,11 @@
<button type="submit">Привязать заново</button> <button type="submit">Привязать заново</button>
</form> </form>
{{end}} {{end}}
{{if .Retriable}}
<form method="post" action="/ui/downloads/{{.ID}}/retry">
<button type="submit">Повторить</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>