Compare commits

...
11 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 b1bca98738 Ревью: preflight готовности источника для команд ревью (MAJOR-5)
Команды ревью проверяли только наличие раздачи в qBittorrent, но не её
готовность. Недокачанную задачу можно припарковать в deferred, затем
«Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/
SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной
Apply не имел preflight завершённости.

Вводим ensureSourceReady (classify(t.State)==classReady) вместо
ensureSourcePresent во всех командах, которым нужен источник (Relink/
Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний
рубеж перед хардлинками. Недокачанный источник → отдельный sentinel
ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и
Telegram, без reconcile (состояние deferred/review легитимно).

Change review-readiness-preflight заархивирован, дельта влита в
openspec/specs/review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:40:49 +03:00
avandClaude Opus 4.8 a43adea723 Беклог: переоформить path-2 удаления в одну задачу «полное удаление»
Обсудили: для сервиса одного окна основное действие — удалить всё разом (раздачу
+ файлы в библиотеке), чтобы освободить место. Из-за хардлинков иначе место и не
вернуть — снять надо обе ссылки на инод. Задача udalenie-edinoe-okno переписана в
одну конкретную:

- действие «Удалить» на загрузке: снять живые хардлинки (undo-механика, superseded
  пропускаем) + удалить раздачу с файлами из qBittorrent (новый метод qbt);
  осознанно обходим предохранитель последней копии; обязательное подтверждение;
- undo (перераспознать, гард включён) vs delete (освободить место, гард выключен)
  зафиксированы как разные действия;
- терминальное состояние — переиспользуем deleted, инициатора (user/reconcile)
  пишем в лог перехода; в граф добавить ребро done→deleted;
- тайтл-группа и экран состава выкинуты как оверинжиниринг; мультивыбор в списке —
  опциональный шаг.

Реализовать как OpenSpec-change. Приоритет высокий (недостающая половина одного
окна, частый сценарий «досмотрел → освободить место»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:43:26 +03:00
avandClaude Opus 4.8 2ed42b3e18 Беклог: решение по confidence-гейту (вариант B, дефолт 0.7)
Обсудили расхождение спека↔код по авто-раскладке. Решение: гейт по confidence
оставляем — низкий confidence служит доп. проверкой на ревью (LLM могла ошибиться
так, что под ошибку нашёлся самосогласованный матч в базе). Задача
gate-confidence-spec-vs-code переоформлена из «определиться» в конкретную:
сделать порог реально выключаемым (0 = выкл, сейчас 0 молча возвращается к
дефолту), понизить дефолт 0.85→0.7, узаконить confidence в спеке recognition как
конфигурируемое четвёртое условие, дока + тесты. Реализовать как OpenSpec-change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:31:05 +03:00
avandClaude Opus 4.8 8ad4273db2 Беклог: ревью задач и приоритетов по итогам сверки с кодом
Прошлись по 34 старым задачам, сверив премисы с текущим кодом и спеками:

- Удалена mashina-sostoyaniy-biblioteka: change state-transition-graph
  (архив 2026-07-08) осознанно отверг внешнюю FSM-библиотеку в пользу
  своего декларативного графа — задача противоречит принятому решению.
- Понижены: masshtab-100-zagruzok (высокий→средний, чисто документный НФТ
  без признаков реальной нагрузки), favicon / telegram-match-metabazy /
  disk-kopii-video-ts-bdmv (средний→низкий).
- Переформулированы под реальный остаток: dobavlenie-edinoe-okno (magnet и
  .torrent-файл уже сделаны — остался фетч .torrent по URL с SSRF-гардом,
  средний→низкий); vneshnie-subtitry (привязка субтитр→серия уже работает —
  сузили до пар VobSub .idx+.sub и потери Lang/Flags в toLayoutPlan).
- Индекс README пересобран под новые ярусы (высокий/средний/низкий = 9/19/19).

Остальные задачи подтверждены кодом как актуальные; повышать нечего.
gate-confidence и дробление udalenie оставлены как темы для обсуждения.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:05:41 +03:00
avandClaude Opus 4.8 7a774ad53d Беклог: перенос из Tududi в docs/backlog (файл на задачу + индекс)
Tududi оказался неудобен для ведения беклога проекта — переходим на файлы в
репозитории. Каждая задача — отдельный markdown в docs/backlog/ (48 файлов),
плюс индекс README.md со списком по приоритетам и хуками. Тело файла хранит
исходное описание (контекст, решения, ссылки на спеки/ADR/черновики).

CLAUDE.md: источник истины по беклогу теперь docs/backlog/; Tududi понижен до
инбокса сырых идей. Живые ссылки в спеках (recognition, architecture,
review-ux, jellyfin-layout) «в беклоге (Tududi)» переписаны на прямые ссылки
на файлы беклога.

Перенесённые задачи удалены из Tududi; завершённые оставлены как история.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:43:37 +03:00
avandClaude Opus 4.8 14d615a7c2 Приём: добавление загрузки по .torrent-файлу
Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и
Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает
там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум
контекста для распознавания без сети.

- internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и)
  (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени,
  дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно
  (недоверенный вход).
- Персистентность байтов: таблица-спутник download_torrent (миграция 0009);
  пишется в транзакции создания загрузки, только на ветке создания (не при
  дедупе). Байты живут весь срок строки — нужны для повторного добавления
  при retry.
- ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref —
  человекочитаемый референс (имя раздачи/файла), не адрес добавления.
- worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях —
  processCatched и Retry (torrent добавляется файлом, не magnet-хешем).
- Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём
  Telegram-документа (скачивание с редактированием токена из ошибок — секрет
  не в логи; обработка до ветки pending/текста).

Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью
(дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change
архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:52:31 +03:00
avandClaude Opus 4.8 90fd8640ed Машина состояний: декларативный граф легальных переходов
Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).

Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:17 +03:00
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:29:28 +03:00
avandClaude Opus 4.8 f0ce6b4bc8 CLAUDE.md: уточнение конвенции времени — UTC/RFC 3339
Правило времени приведено к фактической конвенции: храним в UTC (RFC 3339 с
суффиксом Z), генерирует только приложение (store.Now()), таймзона отображения
— конфиг [general].timezone. Ссылка на docs/conventions/database.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:38:33 +03:00
avandClaude Opus 4.8 e9ec26d09a Ingest: контекст распознавания из полей magnet-ссылки
Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без
сети и дополняем им текст от транспорта: пользовательский текст первым, при
пустом — синтез единственный. Обогащённый контекст идёт только в
download.Context (его читают recognition и веб-UI); вход namer и отображаемое
имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут.

- magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез,
  отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер)
- Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape)
- ingest: mergeContext → download.Context, namer на сыром req.Context
- Влита дельта capability ingest в openspec/specs, change заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:38:01 +03:00
avandClaude Opus 4.8 6d801ed03b Беклог: перенос в Tududi как единственный источник
Задачи беклога перенесены в Tududi (проект jellybit) с приоритетами и
описанием. Файл docs/backlog.md удалён; CLAUDE.md указывает на Tududi как
единственный источник. Живые ссылки в спеках на backlog.md переписаны на
отсылку к задаче в беклоге (Tududi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:55:53 +03:00
131 changed files with 6253 additions and 894 deletions
+19 -1
View File
@@ -91,6 +91,22 @@ OpenSpec (пилот — `ingest`). До переноса источник ис
- `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не - `docs/drafts/` — черновики: планы, идеи, ещё не принятые решения. Не
источник истины. источник истины.
## Задачи и беклог
- **Единственный источник беклога — каталог [docs/backlog/](docs/backlog/README.md)**:
одна задача = один markdown-файл (`docs/backlog/<slug>.md`), плюс
индекс [README.md](docs/backlog/README.md) со списком по приоритетам
(высокий/средний/низкий) и хуками. В теле файла — контекст, принятые
решения, шаги и ссылки на спеки/ADR/черновики. Заводи задачу новым файлом
и строкой в индексе; закрытую (реализованную) — удаляй, суть переезжает в
`docs/specs`/`docs/adr`.
- Спекулятивные задачи (ещё без решения «делаем») помечены префиксом
`[идея]` в названии — их сперва прорабатываем.
- **Tududi — только инбокс сырых идей** (проект `jellybit`, project_id 14).
Беклог там больше не ведём; идея из Tududi становится задачей, когда её
оформляют файлом в `docs/backlog/`. Прежняя единая `docs/backlog.md`
доступна в истории git.
## Язык ## Язык
- Документация, комментарии, сообщения коммитов — **русский**. - Документация, комментарии, сообщения коммитов — **русский**.
@@ -123,7 +139,9 @@ Module path — `git.vakhrushev.me/av/jellybit`. Go 1.26, `CGO_ENABLED=0`.
- Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в - Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте: файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
[docs/conventions/config.md](docs/conventions/config.md). [docs/conventions/config.md](docs/conventions/config.md).
- Время — всегда с явным TZ (сервер в `Europe/Moscow`). - Время — храним в UTC, RFC 3339 с суффиксом `Z`; генерирует только приложение
(`store.Now()`), таймзона отображения — конфиг `[general].timezone`:
[docs/conventions/database.md](docs/conventions/database.md).
- Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых - Идентификаторы — TEXT ULID (lowercase) через `internal/ident`, без числовых
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе: AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
[docs/conventions/database.md](docs/conventions/database.md). [docs/conventions/database.md](docs/conventions/database.md).
+8 -9
View File
@@ -80,13 +80,13 @@ func runServe(args []string) error {
} }
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM // Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
// работает только алгоритмический фолбек. // работает только алгоритмический фолбек. Namer зовёт worker на шаге
// добавления пойманной загрузки (не синхронный приём).
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger) namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
ingestor := ingest.New(st, qb, namer, ingest.Config{ // Быстрый приём: сохраняет загрузку в catched и сразу отвечает; добавление в
Category: cfg.QBittorrent.Category, // qBittorrent и вывод имени делает worker (см. download-tracking).
SavePath: cfg.QBittorrent.SavePath, ingestor := ingest.New(st, logger)
}, logger)
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review. // Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
providers, err := metadataProviders(cfg, logger) providers, err := metadataProviders(cfg, logger)
@@ -126,8 +126,11 @@ func runServe(args []string) error {
PollInterval: cfg.Worker.PollInterval.Std(), PollInterval: cfg.Worker.PollInterval.Std(),
StuckAfter: cfg.Worker.StuckAfter.Std(), StuckAfter: cfg.Worker.StuckAfter.Std(),
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
CatchTimeout: cfg.Worker.CatchTimeout.Std(),
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold, SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
}, logger) }, logger)
// Вывод имени на шаге добавления пойманной загрузки (best-effort).
wrk.SetNamer(namer)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin // Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
// не валит сервис — скан просто не сработает (залогируется в воркере). // не валит сервис — скан просто не сработает (залогируется в воркере).
@@ -188,10 +191,6 @@ 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 string) {
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
View File
@@ -67,6 +67,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
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 = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
catch_timeout = "10m" # страховочный предел: пойманная (catched) задача не добавилась в qBittorrent за это время → failed; Go-duration
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
[recognition] [recognition]
-484
View File
@@ -1,484 +0,0 @@
# Беклог
Единый список будущих задач по проекту: то, что уже решили сделать, и
идеи, которые ещё надо обдумать. Это не план реализации (он — в
[drafts/roadmap.md](drafts/roadmap.md)) и не источник истины: принятое и
реализованное переезжает в `docs/specs`/`docs/adr`.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к
порядку. Спекулятивные пункты (ещё без решения «делаем») помечены
_(идея)_ — их сперва надо проработать.
## Высокий
### Проблема второго сезона
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
новый сезон должен лечь в **ту же** папку сериала, а не завести рядом почти
одинаковую вторую. Разбор ([drafts/logical-title-model.md](drafts/logical-title-model.md))
показал: проблема не в группировке, а в **сходимости папки** — папка каждый
раз печатается заново из выхода LLM, и совпадение `provider_id` не
гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год
сериала). Отдельная сущность «тайтл» **не вводится**; решение — правило
сходимости при построении плана: при подтверждённом матче наследовать базу
папки от живых `file_link`'ов загрузок с тем же `(provider, provider_id)`,
игнорируя LLM-выход; якоря нет — папка из распознавания, как сейчас (первая
загрузка «печатает» имя).
- [ ] lookup живых ссылок по `(provider, provider_id)` через current recognition
- [ ] наследование базы папки (имя + год) при построении плана раскладки
- [ ] рассинхрон (несколько живых папок с одним матчем) → review, не молча
- [ ] тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md) §5.2,
[recognition.md](specs/recognition.md) (модель уверенности,
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
сериала с провайдер-id).
### Раздачи с докачиванием (слияние при повторном добавлении)
Свежий сериал раздают по мере выхода: торрент содержит 5 эпизодов из 10,
позже его перезаливают целиком, и пользователь добавляет раздачу повторно.
Решение проработано ([drafts/logical-title-model.md](drafts/logical-title-model.md)
§6.2): новая загрузка приходит в ту же папку за счёт правила сходимости, а
раскладка становится **merge** — доложить только недостающее. Существующие
пути не трогаем (never-overwrite, владение остаётся у старой загрузки),
новые кладём (владеет новая). Split-ownership сезона (серии поделены между
загрузками) принят как норма per-path модели; обе раздачи сидируют
независимо.
- [ ] в плане раскладки отличать «путь занят живой ссылкой того же матча»
(→ пропустить) от настоящей коллизии (→ review, как сейчас)
- [ ] merge-раскладка: существующее пропустить, недостающее доложить
- [ ] показать итог в карточке: сколько доложено, сколько уже было
- [ ] решить «слияние загрузок» при перезаливе той же вещи (одна строка
`download` + новый infohash vs новая загрузка) — открытый вопрос
черновика §10
Зависит от правила сходимости ([«Проблема второго
сезона»](#проблема-второго-сезона)) и выигрывает от ULID-идентичности.
Связано: [jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка,
идемпотентность), [workflow.md](specs/workflow.md) (повторный прогон
загрузки).
### Удаление средствами jellybit («единое окно», path 2)
Распознавание **ручного** удаления (источник из qBittorrent / цель из
Jellyfin) и пометка рассинхрона уже сделаны: фоновая сверка по матрице
«источник × цель» → состояния `target_missing`/`orphaned`/`deleted`,
безопасный `undo` (не снимает последнюю копию, `nlink <= 1`), синхронный
preflight перед действиями. См. `openspec/specs/state-reconciliation/`,
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Решения из
разбора ([drafts/logical-title-model.md](drafts/logical-title-model.md)
§5.3, §6.4): «тайтл» — вычисляемая группа загрузок по
`(provider, provider_id)` / общей папке, без новой сущности; удаление
целиком — обход загрузок группы штатным undo; удаление раздачи из
qBittorrent — осознанный выход за инвариант «источник неприкосновенен»,
только по явному подтверждению (не случайному клику).
- [ ] удаление одной загрузки: снять её живые хардлинки (штатный undo,
`superseded` пропускаем, `nlink`-гард) + опц. удалить раздачу из
qBittorrent с файлами — с осознанным подтверждением
- [ ] вычисляемая группа «тайтл» в UI: состав сериала/фильма (загрузки,
сезоны, файлы) одним экраном
- [ ] удаление тайтла целиком: обход загрузок группы + опц. снос
опустевшей папки
- [ ] после полного удаления память о тайтле не остаётся (линза без
содержимого не нужна)
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md),
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
[workflow.md](specs/workflow.md).
### Ретеншн и очистка БД
Терминальные задачи (`done`/`cancelled`/`failed`/`reverted`), их попытки
`recognition` с сырыми ответами LLM и `metadata_candidate` копятся вечно —
со временем БД и список загрузок распухают и становятся нечитаемыми. Нужна
авточистка старше N дней (с настройкой в `[storage]` или `[worker]`) и/или
ручное удаление. Маленькая задача, но без неё интерфейс деградирует по мере
эксплуатации.
Связано: [architecture.md](specs/architecture.md) → «Хранилище» (таблицы
`download`/`recognition`/`metadata_candidate`/`file_link`), пакет `store`.
### Eval-харнес распознавания (корпус кейсов + метрика точности)
Распознавание — ядро продукта, но смена модели или правка промпта сейчас
вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские
релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по
нему с метрикой точности (тип/название/год/нумерация). Тогда можно
сравнивать LLM-провайдеры и версии промпта по числам, а не на ощупь.
Прогон — отдельной командой (`jellybit eval` или тестом), на фикстурах, без
реального qBittorrent.
Связано: [recognition.md](specs/recognition.md) (конвейер, модель
уверенности), пакет `recognize`.
### НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)
Сейчас потолок по нагрузке нигде не зафиксирован: воркер, поллинг
qBittorrent, пул LLM-вызовов и запись в SQLite спроектированы «на глаз».
Записать в **нефункциональные требования** целевой ориентир — архитектура
держит до **100 одновременных загрузок** в работе (приём → распознавание →
раскладка), план-максимум — **1000**. Сама запись требования дешева и
высокоценна: она задаёт рамку для решений ниже по списку. Отдельно (уже
дороже) — аудит узких мест под эту цифру: одиночное соединение SQLite и
сериализация записи, конкурентность воркера и лимит параллельных
распознаваний, частота/стоимость поллинга и дедуп при наплыве.
Связано: [architecture.md](specs/architecture.md) → «Отслеживание
загрузки»/«Хранилище», пакеты `worker`, `store`, `qbt`, `llm`.
## Средний
### Словарь единого языка (ubiquitous language)
Свести термины домена в один глоссарий, чтобы пользователь, документация,
код и агент говорили на одном языке: загрузка, раздача, распознавание, матч,
кандидат, раскладка, источник/цель, хардлинк, ревью, переход состояния и
т.д. — русский термин, английский идентификатор в коде, краткое определение.
Сейчас наименования расходятся между спеками, UI и кодом, и в диалоге с
агентом приходится каждый раз сверять понятия. Глоссарий — источник истины
по именам; на нём же строится агент-ревьювер наименований (см.
[«Агенты-ревьюверы качества»](#агенты-ревьюверы-качества-наименования-архитектура-конвенции-стиль)).
Связано: [docs/conventions](conventions/README.md) (кросс-каттинг),
[architecture.md](specs/architecture.md) (домен), новый файл-глоссарий.
### Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
Набор узких сабагентов-ревьюверов поверх ревью-процесса из `CLAUDE.md`,
каждый со своей оптикой: соответствие наименований словарю единого языка,
соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты
безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля
кода на высоком уровне и поиск дублирования. Запускаются как чекпоинт перед
`archive`/коммитом. Развивает ревью-процесс OpenSpec в сторону
воспроизводимых автоматических проверок, не заменяя человеческое ревью.
Связано: `CLAUDE.md` (ревью-процесс, конвенции),
[docs/conventions](conventions/README.md),
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
### Сила совпадения кандидата и пересмотр распознавания/матчинга _(идея)_
Сейчас у кандидата метабазы нет метрики силы совпадения (`metadata_candidate`
хранит provider/id/title/year/url), а решение «авто vs review» — по правилу
«единственный сильный матч + валидация», не по числовой уверенности. Для
ревью это значит: список кандидатов нечем отсортировать/подсветить по
уверенности — берём порядок сбора. Идея — ввести на этапе матча **силу
совпадения кандидата** (точное совпадение названия+года vs частичное) для
сортировки и подсказки в UI. Шире — отдельно продумать **сам процесс
распознавания и матчинга**: границы «разбор LLM / поиск в базе / сверка»,
что храним у кандидата, как считаем и показываем уверенность. Требует
проработки перед реализацией.
Связано: [recognition.md](specs/recognition.md) (модель уверенности),
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md),
[review-ux.md](specs/review-ux.md) (выбор источника в ревью, реализовано),
[«Пересмотр набора capabilities»](#пересмотр-набора-capabilities-и-рефакторинг-спек).
### История переходов загрузки
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто
инициировал — воркер, человек, сверка), а не только текущее состояние.
Сейчас по задаче виден лишь актуальный статус, а разбор «как мы сюда попали»
идёт по логам сервера. Отдельная таблица истории даёт лог переходов в
карточке/расширенной информации и фундамент для метрик длительности стадий.
Естественно ложится на собственный идентификатор загрузки.
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
переходов ложится в него;
[drafts/logical-title-model.md](drafts/logical-title-model.md) §5.4 (схема
`state_transition`, actor `worker|human|reconcile`),
[workflow.md](specs/workflow.md) (граф состояний),
[database.md](specs/database.md), пакеты `worker`, `store`.
### Машина состояний на go-библиотеке
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
для описания воркфлоу/машины состояний и перевести переходы на неё — ради
декларативности, проверяемости переходов и единого места правды. Кандидаты
для оценки: `looplab/fsm`, `qmuntal/stateless` (и аналоги). Граф и переходы
уже формализованы — переносим один в один.
Связано: [workflow.md](specs/workflow.md) (текущий граф состояний).
### Привязка уведомлений к источнику в ботах (мульти-бот)
Уведомления и запросы подтверждения должен получать тот, кто прислал
загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней.
Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и
др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся
**единым для всех** и точкой правды по функциональности (боты — тонкие
адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и
идентификатор отправителя, маршрутизировать пинги по нему.
Связано: [review-ux.md](specs/review-ux.md) (разделение труда транспортов,
веб = точные правки), [architecture.md](specs/architecture.md) →
«Транспорты».
### Улучшения UI: показывать матч с записью метабазы
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
показывают, **с какой именно записью** метабазы (TMDB/TVDB/IMDb) сматчилась
загрузка — провайдер, id и ссылку на запись. Осталось довести то же в
**Telegram**: в уведомлениях/подтверждениях показывать запись матча (название,
год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота.
Полный выбор источника в вебе уже реализован — см.
[review-ux.md](specs/review-ux.md).
Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md)
(матч в базе), [architecture.md](specs/architecture.md) → «Транспорты».
### Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки _(идея)_
Обычный случай сериальной раздачи — один сезон (его номер надо сразу видеть
глазами и сверять на ревью — под это сделана сводка сезонов в инфо-части, см.
`openspec/specs/review`). Но в редких заказах раздача бывает сложнее: **все
сезоны сериала разом**, **пак нескольких сезонов**, смешанная нумерация, вложенные
папки сезонов, разнобойные имена файлов. Сейчас `PlanFile.Season` задаётся на
каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не
проработаны: как надёжно распознать многосезонную раздачу, как показать её на
ревью (сводка — лишь страховка, не полноценный разбор по сезонам), как разложить
и как это стыкуется со сходимостью папки и merge-докачиванием. Проработать
крайние случаи и решить, что поддерживаем явно, а что уводим в ревью как «сложную
раскладку».
Связано: [recognition.md](specs/recognition.md) (сезон-паки, нумерация),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка сезонов),
[review-ux.md](specs/review-ux.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 (отдаём их в
qBittorrent, без исходящих запросов на пользовательский URL — SSRF
исключён). Идеал — одно поле «единого окна»: кидаем туда текст или файл, а
сервис сам разбирает, что это (magnet / ссылка / .torrent / сообщение
бота), и заводит загрузку.
Связано: [architecture.md](specs/architecture.md) → «Транспорты»
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
поддержан только magnet).
### Бэкап SQLite
`architecture.md` требует «бекапить data-том», но *как* — не описано. Без
понятной стратегии сбой или редеплой стирают всё in-flight состояние.
Зафиксировать решение и реализовать: периодический `VACUUM INTO` в
`/data/backups` по расписанию (с ротацией) либо потоковая репликация
(litestream). Лучше сделать, пока БД маленькая.
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
«бекапить-и-не-терять»), пакет `store`.
### Глубокий healthcheck и статус зависимостей
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
недоступны — узнаёшь лишь по застрявшим задачам. Нужна readiness-проверка
ключевых зависимостей и отражение их состояния в UI (бейдж «qBittorrent
недоступен»), чтобы причина простоя была видна сразу.
Связано: [architecture.md](specs/architecture.md) → «Деплой» (healthcheck),
пакеты `qbt`, `llm`, `metadata`, `httpapi`.
### Обучение на правках человека (few-shot из прошлых ревью)
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и
подмешивать похожие в будущие промпты. Системно повышает точность на «твоих»
трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой
верификации, но дешевле: учимся на уже собранных `hint`/`override`.
Связано: [recognition.md](specs/recognition.md) (конвейер, промпт),
[«Многоступенчатая верификация»](#многоступенчатая-верификация-привязки-идея),
[architecture.md](specs/architecture.md) → «Хранилище» (`hint`, `override`).
### Гейт авто-раскладки по `confidence`: спека vs код
Аудит спек↔код (2026-07-03) нашёл расхождение в модели уверенности.
Спека `recognition` (унаследовано из `recognition.md`) утверждает, что
самооценка LLM `confidence`**вспомогательный сигнал, НЕ единственный гейт**:
при подтверждённом матче в базе + чистой структурной валидации + согласованности
сигналов авто-раскладка допускается. Код же (`internal/recognize/validate.go`,
`confidence < AutoThreshold`, дефолт 0.85) делает `confidence` **жёстким
блокирующим условием**: план с матчем и чистой валидацией, но `confidence` 0.5
уйдёт в review вопреки сценарию спеки. Нужно определиться, что правда: либо
признать порог `AutoThreshold` в спеке как легитимный гейт (скорее так — код его
осознанно ввёл конфигом), либо ослабить код. Заодно `AutoThreshold` как
конфигурируемый гейт спекой не описан.
Связано: `openspec/specs/recognition` (требование «Модель уверенности и решение
auto/review»),
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md),
пакет `recognize`.
### Привязка внешних субтитров к серии (сериалы)
Аудит спек↔код (2026-07-03): спека `recognition` требует «внешние субтитры SHALL
привязываться к соответствующему видео». Для **фильма** это работает — раскладка
именует субтитр по базе видеофайла. Для **сериала** связь субтитр→конкретная
серия не выражена: в `PlanFile` (`internal/recognize`) нет поля привязки, и нет
логики спаривания VobSub `.idx`+`.sub`. Нужно смоделировать привязку субтитра к
эпизоду (поле на `PlanFile` или роль с указанием `season`/`episode`) и спаривание
`.idx`+`.sub`, либо — если поддержку откладываем — сузить формулировку спеки до
реального поведения.
Связано: `openspec/specs/recognition` (требование «Роли файлов на краях»),
[jellyfin-layout.md](specs/jellyfin-layout.md) (имена субтитров), пакеты
`recognize`, `layout`.
### Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
Иногда для очень редких фильмов скачивается не один видеофайл, а **полная копия
диска** — структура `VIDEO_TS/` (DVD: `VIDEO_TS.IFO`, `VTS_01_1.VOB`…) или
`BDMV/` (BluRay: `BDMV/STREAM/*.m2ts`, `index.bdmv`). Сейчас распознавание и
раскладка заточены под пофайловый разбор (один main-видеофайл фильма / серии
сериала), а тут «фильм» — это **каталог целиком**. Jellyfin такие раскладки
поддерживает (папка фильма с вложенным `VIDEO_TS`/`BDMV`), нам нужно: распознать,
что раздача — это образ диска (по наличию `VIDEO_TS`/`BDMV`), не пытаться
разбирать её по отдельным VOB/m2ts как серии, и разложить весь каталог диска
хардлинками в папку фильма Jellyfin (`Название (Год)/VIDEO_TS/…`). Крайний, но
реальный случай для редких изданий; частота низкая, поэтому в «Среднем».
Связано: [recognition.md](specs/recognition.md) (роли файлов, что игнорируем),
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка фильма, крайние
случаи), пакеты `recognize`, `layout`.
## Низкий
### Панель действий ревью вне htmx-свопа блока источника
При выборе источника одним кликом обновляется только блок источника
(`#source-block`) htmx-свопом, а нижняя панель действий (кнопка «Применить»,
завязанная на `HasLinks`) — вне блока и не обновляется до полной перезагрузки.
Практически не мешает (хардлинки только по явному «Применить»,
`Apply` без плана вернёт ошибку), но в краевом случае (источник с пустым
предпросмотром из-за коллизии) кнопка «Применить» может остаться/пропасть не
синхронно. Решение намечено в дизайне `review-unified-source-block`
(Risks/Trade-offs): обновлять панель `hx-swap-oob` из того же партиала.
Связано: `openspec/specs/review`, `openspec/specs/web-ui`, пакет `httpapi`.
### Мгновенные обновления через SSE
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто
и работает, но с задержкой в интервал опроса и холостыми запросами.
Перевести динамический контент (прогресс загрузки, смена статуса, раздача)
на Server-Sent Events, чтобы обновления приходили почти мгновенно и без
лишнего поллинга. Поллинг работает, поэтому это улучшение, а не блокер; SSE —
один долгоживущий ответ на соединение, ложится на server-rendered UI без
тяжёлого фронтенда.
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
По калибровке болей (2026-07-02,
[drafts/logical-title-model.md](drafts/logical-title-model.md) §6.3) —
**не боль**, из приоритета выпало. Сосуществование версий доступно уже
сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же
путь штатно уходит в review. Явный replace (undo старого хардлинка → lay
нового → супересид владения путём) — отдельный change, если/когда станет
болью.
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия).
### Многоступенчатая верификация привязки _(идея)_
Несколько раз извлекать данные из раздачи и контекста разными промптами,
искать в метабазах, затем сводить результаты в общий вердикт
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
запросов к базам. Требует проработки: когда включать, как мерджить
расхождения, стоимость/латентность.
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
уверенности).
### Выбор из нескольких находок метабазы в Telegram
Когда распознавание даёт несколько подходящих кандидатов в метабазе,
предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча
брать первый/лучший. Веб остаётся точкой точных правок (полный выбор
источника уже реализован — см. [review-ux.md](specs/review-ux.md)), бот —
быстрый выбор из готового короткого списка.
Связано: [review-ux.md](specs/review-ux.md) (боты — быстрые действия, веб —
точные правки), [recognition.md](specs/recognition.md) (кандидаты матча).
### Проверка свободного места перед copy-fallback
Когда хардлинк невозможен (`EXDEV`/`ENOTSUP`/…), `layout` копирует файл,
дублируя место на диске. На забитом диске это упрётся в полку посреди
раскладки. Перед копированием проверять доступное место и при нехватке
внятно уходить в `failed` с понятной причиной, а не падать на полпути.
Связано: [architecture.md](specs/architecture.md) → «Раскладка файлов»
(фолбэк-копирование), пакет `layout`.
### Кэш метабаз (и опционально LLM)
Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и
тем же запросом. Кэш ответов с TTL экономит лимиты API и ускоряет «Распознать
заново»/«Уточнить». При желании — кэш ответов LLM по хешу входа (но он менее
полезен, т.к. вход меняется подсказками).
Связано: [recognition.md](specs/recognition.md) (сверка с базой), пакеты
`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
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
отзывчивое, удобное с телефона). Текущий server-rendered UI функционален,
поэтому это улучшение, а не блокер; большой объём работы.
Связано: [review-ux.md](specs/review-ux.md) (веб = точные правки),
пакет `httpapi`.
+70
View File
@@ -0,0 +1,70 @@
# Беклог
Единый список будущих задач по проекту: то, что уже решили сделать, и идеи,
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача = один
файл в этом каталоге. Не план реализации и не спецификация: принятое и
реализованное переезжает в [`docs/specs`](../specs)/[`docs/adr`](../adr), а сам
пункт беклога удаляется.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
Спекулятивные пункты (ещё без решения «делаем») помечены префиксом `[идея]` в
названии — их сперва надо проработать. Пункты, помеченные _(ревью 2026-07-08)_,
пришли из тщательного ревью ingest/worker/жизненного цикла (см. общий тег в теле).
Tududi (проект `jellybit`) больше **не** держит беклог — он служит только
инбоксом сырых идей. Прежде чем идея станет задачей, её оформляют файлом здесь.
## Высокий
- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти…
- [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают…
- [Полное удаление загрузки из jellybit («единое окно», path 2)](udalenie-edinoe-okno.md) — Действие «Удалить»: снять хардлинки + снести раздачу+файлы из qBittorrent → освободить место (отдельно от undo)
- [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми…
- [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
- [Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)](review-f1-gate-dozapisi-heshey.md) — dedup-ветка дописывает все хеши в найденную задачу без гарда — риск инварианта ≤1 активной _(ревью 2026-07-08)_
- [Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)](review-major1-2-retry-stall.md) — таймаут и простой отсчитываются от возраста торрента, а не от начала загрузки _(ревью 2026-07-08)_
- [Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)](review-major3-zombie-downloading.md) — торрент пропал из qBittorrent в downloading → задача вечный зомби, никто не двигает _(ревью 2026-07-08)_
## Средний
- [Словарь единого языка (ubiquitous language)](ubiquitous-language-slovar.md) — Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент…
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE
- [[идея] Сила совпадения кандидата и пересмотр распознавания/матчинга](sila-sovpadeniya-kandidata.md) — ИДЕЯ (сперва проработать)
- [История переходов загрузки](istoriya-perehodov-zagruzki.md) — Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал…
- [Привязка уведомлений к источнику в ботах (мульти-бот)](uvedomleniya-multi-bot.md) — Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор…
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)](review-major4-sweep-linking.md) — задачи застревают в linking при рестарте; заодно фикс persist-failure _(ревью 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)_
- [transition() глотает ошибки перед созданием хардлинков (MINOR-7)](review-minor7-transition-errors.md) — transition() глотает ошибку перед хардлинками — задача застревает в review _(ревью 2026-07-08)_
- [.torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)](review-f6-torrent-over-magnet.md) — .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь _(ревью 2026-07-08)_
## Низкий
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
- [Кэш метабаз (и опционально LLM)](kesh-metabaz.md) — Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же…
- [[идея] guessit как сервис-спутник](guessit-sputnik.md) — ИДЕЯ
- [[идея] Завершение загрузки через webhook](webhook-zavershenie-zagruzki.md) — ИДЕЯ (решим по опыту эксплуатации)
- [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — Решено для v1: без авторизации в доверенной LAN, опц
- [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое…
- [Сделать фавиконку для jellybit](favicon.md) — мелкая косметика: иконка вкладки/PWA для веб-UI
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)](review-f7-f10-ingest-ui-fixes.md) — мелочи приёма/UI: oversized→500, гонки дедупа, парсинг magnet, cap контекста, руны _(ревью 2026-07-08)_
- [Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)](review-lifecycle-minor.md) — claim-token распознавания, I/O под глобальным mutex, мелкие lookup/retry _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
@@ -0,0 +1,7 @@
# Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)
**Приоритет:** средний
Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md, каждый со своей оптикой: соответствие наименований словарю единого языка, соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля кода и поиск дублирования. Запускаются как чекпоинт перед archive/коммитом. Развивает ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка».
@@ -0,0 +1,7 @@
# Аниме с абсолютной нумерацией
**Приоритет:** средний
Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт SxxEyy. Нужен пересчёт абсолютной нумерации в сезон/серию — надёжнее всего через TVDB (там есть absolute order). Отдельный крайний случай распознавания; на стороне ревью — веб-хелпер «absolute → S·E».
Связано: specs/recognition.md (конвейер, сезон-паки), specs/jellyfin-layout.md (нумерация серий), specs/review-ux.md.
+7
View File
@@ -0,0 +1,7 @@
# Авторизация веб-UI (на будущее)
**Приоритет:** низкий
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей (http.trusted_subnets) — как умеет qBittorrent. Если понадобится защита: токен/Basic в самом приложении или вынос за reverse-proxy с аутентификацией.
Связано: specs/architecture.md → «Транспорты» (доступ к веб-UI), пакет httpapi.
+7
View File
@@ -0,0 +1,7 @@
# Бэкап SQLite
**Приоритет:** средний
architecture.md требует «бекапить data-том», но как — не описано. Без понятной стратегии сбой или редеплой стирают всё in-flight состояние. Зафиксировать решение и реализовать: периодический VACUUM INTO в /data/backups по расписанию (с ротацией) либо потоковая репликация (litestream). Лучше сделать, пока БД маленькая.
Связано: specs/architecture.md → «Деплой» (data-том), пакет store.
+7
View File
@@ -0,0 +1,7 @@
# Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)
**Приоритет:** низкий
Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска — структура VIDEO_TS/ (DVD) или BDMV/ (BluRay). Сейчас распознавание и раскладка заточены под пофайловый разбор, а тут «фильм» — это каталог целиком. Jellyfin такие раскладки поддерживает (папка фильма с вложенным VIDEO_TS/BDMV). Нужно: распознать, что раздача — образ диска (по наличию VIDEO_TS/BDMV), не разбирать её по отдельным VOB/m2ts как серии, разложить весь каталог хардлинками в папку фильма (Название (Год)/VIDEO_TS/…). Крайний, но реальный случай; частота низкая.
Связано: specs/recognition.md (роли файлов), specs/jellyfin-layout.md (раскладка фильма), пакеты recognize, layout.
+18
View File
@@ -0,0 +1,18 @@
# Фетч .torrent по URL — остаток «единого окна»
**Приоритет:** низкий
Приём magnet и `.torrent`-файла уже реализован: ветка `TorrentData → torrent.Parse`
(`internal/ingest/ingest.go`), файл-пикер в веб-форме (`web/templates/index.html`),
приём документа в Telegram (`internal/tgbot`). Осталась одна ветка «единого окна» —
**URL на `.torrent`**: сервис сам скачивает файл по ссылке и заводит загрузку. Была
осознанно вынесена из change `torrent-file-ingest`, потому что требует исходящего
запроса на пользовательский URL → нужен SSRF-гард (allowlist схем/хостов, запрет
приватных сетей, лимит размера/редиректов).
Идеал по-прежнему — одно поле, куда кидают текст или файл, а сервис разбирает, что
это (magnet / ссылка на .torrent / .torrent-файл / сообщение бота). Сейчас текстовое
поле идёт только через `magnet.Parse`.
Связано: specs/architecture.md → «Транспорты» (source_type = magnet|torrent|url уже в
схеме), пакет ingest, архив change `torrent-file-ingest`.
@@ -0,0 +1,7 @@
# Eval-харнес распознавания (корпус кейсов + метрика точности)
**Приоритет:** высокий
Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую: регрессий не видно. Нужен корпус размеченных кейсов (русские релизы, аниме, сезон-паки, репаки, спецвыпуски) и прогон распознавания по нему с метрикой точности (тип/название/год/нумерация). Тогда можно сравнивать LLM-провайдеры и версии промпта по числам. Прогон — отдельной командой (jellybit eval или тестом), на фикстурах, без реального qBittorrent.
Связано: specs/recognition.md (конвейер, модель уверенности), пакет recognize.
+5
View File
@@ -0,0 +1,5 @@
# Сделать фавиконку для jellybit
**Приоритет:** низкий
_Описание не заполнено._
@@ -0,0 +1,47 @@
# Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)
**Приоритет:** средний
Аудит спек↔код (2026-07-03) нашёл расхождение: спека recognition считает
`confidence` вспомогательным сигналом (условия авто — только матч в базе +
чистая валидация + согласованность), а код (`internal/recognize/validate.go:102`)
добавляет `confidence < threshold` (дефолт 0.85) четвёртым **блокирующим**
условием.
**Решение (2026-07-08): оставляем гейт (вариант B).** Низкий confidence — это
полезная доп. проверка на ревью: LLM могла ошибиться так, что под ошибочные
данные в базе нашёлся «такой же» фильм/сериал (ложный, но самосогласованный
матч — матч и валидация чисты, а модель при этом не уверена). Такой случай ловит
именно порог, уводя задачу в review, а не в авто. Значит confidence остаётся
законным условием — но его надо честно оформить.
Что сделать:
1. **Сделать гейт реально выключаемым.** Сейчас `recognize.go:173-175`
(`if threshold <= 0 { threshold = defaultAutoThreshold }`) не даёт выключить
порог: `0` в конфиге молча возвращается к дефолту. Убрать этот фолбэк — дефолт
задаётся один раз при загрузке конфига; значение `0` → гейт пропускается
`decide` уже `p.Confidence < 0` никогда не истинно, достаточно снять
пере-применение дефолта).
2. **Понизить дефолт** `recognition.auto_confidence_threshold` 0.85 → **0.7**
(`config.go:218`, `recognize.go:144 defaultAutoThreshold`). Условия 1–3 несут
корректность; порогу остаётся ловить только по-настоящему неуверенные планы.
3. **Записать в спеку.** В `openspec/specs/recognition/spec.md` (Requirement
«Модель уверенности и решение auto/review») переформулировать: confidence —
явное **четвёртое, конфигурируемое** блокирующее условие (порог
`recognition.auto_confidence_threshold`, `0` = выкл, дефолт 0.7), а не «лишь
вспомогательный сигнал». Добавить сценарий: матч + чистая валидация +
согласованность, но `confidence` ниже порога → review.
4. **Конфиг-конвенция/дока:** описать ключ в `docs/conventions/config.md` (диапазон
[0,1], 0 = выкл, дефолт 0.7).
5. **Тесты:** `decide` с `threshold=0` (гейт выключен, авто при чистых 1–3);
confidence ниже/выше порога при выполненных 1–3.
6. Точное число порога откалибровать позже — для этого есть задача
[eval-харнес распознавания](eval-harness-raspoznavaniya.md) (сейчас гейтим по
неизмеренному сигналу).
Оформить как OpenSpec-change (дельта `recognition` + правки
`validate.go`/`recognize.go`/`config`).
Связано: openspec/specs/recognition, ADR-2026-06-13-auto-link-requires-db-match,
[eval-харнес](eval-harness-raspoznavaniya.md), пакет recognize.
+7
View File
@@ -0,0 +1,7 @@
# [идея] guessit как сервис-спутник
**Приоритет:** низкий
ИДЕЯ. go-ptn слабее питоновского guessit. Если точности пред-парса не хватит — завернуть guessit в крошечный HTTP-сервис (один файл, поставляется рядом с бинарём jellybit) и спрашивать его на шаге пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
Связано: specs/recognition.md → «На будущее» (пред-парс).
+7
View File
@@ -0,0 +1,7 @@
# Глубокий healthcheck и статус зависимостей
**Приоритет:** средний
/healthz проверяет только сам сервис. Если qBittorrent, LLM или метабаза недоступны — узнаёшь лишь по застрявшим задачам. Нужна readiness-проверка ключевых зависимостей и отражение их состояния в UI (бейдж «qBittorrent недоступен»), чтобы причина простоя была видна сразу.
Связано: specs/architecture.md → «Деплой» (healthcheck), пакеты qbt, llm, metadata, httpapi.
@@ -0,0 +1,7 @@
# История переходов загрузки
**Приоритет:** средний
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал — воркер, человек, сверка), а не только текущее состояние. Сейчас по задаче виден лишь актуальный статус, разбор «как мы сюда попали» идёт по логам сервера. Отдельная таблица истории даёт лог переходов в карточке/расширенной информации и фундамент для метрик длительности стадий. Естественно ложится на собственный идентификатор загрузки и уже реализованный экран /download/{id}.
Связано: drafts/logical-title-model.md §5.4 (state_transition, actor worker|human|reconcile), specs/workflow.md, specs/database.md, пакеты worker, store.
+7
View File
@@ -0,0 +1,7 @@
# Кэш метабаз (и опционально LLM)
**Приоритет:** низкий
Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же запросом. Кэш ответов с TTL экономит лимиты API и ускоряет «Распознать заново»/«Уточнить». При желании — кэш ответов LLM по хешу входа (но он менее полезен, т.к. вход меняется подсказками).
Связано: specs/recognition.md (сверка с базой), пакеты metadata, llm.
+7
View File
@@ -0,0 +1,7 @@
# НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)
**Приоритет:** средний
Потолок по нагрузке нигде не зафиксирован: воркер, поллинг qBittorrent, пул LLM-вызовов и запись в SQLite спроектированы «на глаз». Записать в НФТ целевой ориентир — архитектура держит до 100 одновременных загрузок в работе (приём → распознавание → раскладка), план-максимум — 1000. Сама запись требования дешева и высокоценна: задаёт рамку для решений ниже. Отдельно (дороже) — аудит узких мест: одиночное соединение SQLite и сериализация записи, конкурентность воркера и лимит параллельных распознаваний, частота/стоимость поллинга и дедуп при наплыве.
Связано: specs/architecture.md → «Отслеживание загрузки»/«Хранилище», пакеты worker, store, qbt, llm.
+14
View File
@@ -0,0 +1,14 @@
# Раздачи с докачиванием (merge при повторном добавлении)
**Приоритет:** высокий
Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают целиком, пользователь добавляет раздачу повторно. Новая загрузка приходит в ту же папку за счёт правила сходимости, а раскладка становится merge — доложить только недостающее. Существующие пути не трогаем (never-overwrite, владение у старой загрузки), новые кладём (владеет новая). Split-ownership сезона принят как норма per-path модели; обе раздачи сидируют независимо.
Шаги:
- в плане раскладки отличать «путь занят живой ссылкой того же матча» (→ пропустить) от настоящей коллизии (→ review)
- merge-раскладка: существующее пропустить, недостающее доложить
- показать итог в карточке: сколько доложено, сколько уже было
- решить «слияние загрузок» при перезаливе (одна строка download + новый infohash vs новая загрузка) — открытый вопрос черновика §10
Зависит от правила сходимости («Проблема второго сезона»), выигрывает от ULID-идентичности.
Связано: drafts/logical-title-model.md §6.2, specs/jellyfin-layout.md, specs/workflow.md
@@ -0,0 +1,7 @@
# [идея] Многоступенчатая верификация привязки
**Приоритет:** низкий
ИДЕЯ (требует проработки). Несколько раз извлекать данные из раздачи и контекста разными промптами, искать в метабазах, затем сводить результаты в общий вердикт (голосование/консенсус) — выше точность ценой нескольких вызовов LLM и запросов к базам. Проработать: когда включать, как мерджить расхождения, стоимость/латентность.
Связано: specs/recognition.md (конвейер и модель уверенности).
+7
View File
@@ -0,0 +1,7 @@
# Обучение на правках человека (few-shot из прошлых ревью)
**Приоритет:** средний
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать похожие в будущие промпты. Системно повышает точность на «твоих» трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой верификации, но дешевле: учимся на уже собранных hint/override.
Связано: specs/recognition.md (конвейер, промпт), «Многоступенчатая верификация», specs/architecture.md → «Хранилище» (hint, override).
+7
View File
@@ -0,0 +1,7 @@
# Панель действий ревью вне htmx-свопа блока источника
**Приоритет:** низкий
При выборе источника одним кликом обновляется только блок источника (#source-block) htmx-свопом, а нижняя панель действий (кнопка «Применить», завязанная на HasLinks) — вне блока и не обновляется до полной перезагрузки. Практически не мешает (хардлинки только по явному «Применить», Apply без плана вернёт ошибку), но в краевом случае (источник с пустым предпросмотром из-за коллизии) кнопка может остаться/пропасть не синхронно. Решение намечено в дизайне review-unified-source-block: обновлять панель hx-swap-oob из того же партиала.
Связано: openspec/specs/review, openspec/specs/web-ui, пакет httpapi.
+7
View File
@@ -0,0 +1,7 @@
# Ретеншн и очистка БД
**Приоритет:** высокий
Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми ответами LLM и metadata_candidate копятся вечно — БД и список загрузок распухают и становятся нечитаемыми. Нужна авточистка старше N дней (настройка в [storage] или [worker]) и/или ручное удаление. Маленькая задача, но без неё интерфейс деградирует по мере эксплуатации.
Связано: specs/architecture.md → «Хранилище» (download/recognition/metadata_candidate/file_link), пакет store.
@@ -0,0 +1,13 @@
# Гейт дозаписи хешей в dedup-ветке CreateDownloadIfNoActive (F1)
**Приоритет:** высокий · **Теги:** ingest, review-2026-07-08, invariant
Ревью Fable 2026-07-08 (приём). internal/store/download.go:271-290.
Проблема: dedup-ветка CreateDownloadIfNoActive безусловно дописывает ВСЕ хеши norm в найденную активную задачу (INSERT OR IGNORE) без пер-хеш гарда владения — в отличие от AddInfohashes (download.go:382-418), у которого гард есть. Единственная неохраняемая запись хешей — в авторитетном методе инварианта.
Сценарий: активная A владеет v1, активная B владеет v2 того же торрента (split-identity, см. F4) ИЛИ крафт-магнет (F5) → гибрид {v1,v2} дописывает v1 в B → две активные владеют v1. Инвариант «≤1 активная на infohash» нарушен. Спека ingest «Атомарность возврата в активное» это запрещает.
Фикс: применить пер-хеш гард как в AddInfohashes (исключить existing.ID, пропускать хеши чужой активной задачи). Tx уже открыта.
Вердикт: простой фикс (поведение уже обещано спекой).
@@ -0,0 +1,13 @@
# 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).
Вердикт: change (малая спека-дельта download-tracking + код).
@@ -0,0 +1,11 @@
# Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (оба ревьюера: F3 + NIT-13). worker.go:368-390, discover.go:43-50.
Сценарий: задача catched, worker вне w.mu выводит имя (LLM, секунды) + qbt.Add (успех). Параллельно user Cancel (catched→cancelled). PromoteCatched корректно пропускает (гард state='catched', спека соблюдена). НО торрент ДОБАВЛЕН в qBit под нашей категорией, будет качаться/сидировать вечно. Усыновить назад нельзя: adopt через ExistsByInfohash (любое состояние) → хеши cancelled-задачи существуют. Торрент ест диск без видимой задачи и владельца. Спека покрывает переход состояния, но не побочный эффект. Инвариант «источник неприкосновенен» — но этот торрент добавили МЫ после cancel-намерения.
Фикс-опции: (a) re-read state прямо перед Add (сужает окно); (b) при promote-skip из-за cancel — WARN «torrent left in qBittorrent»; (c) scoped delete/pause только что добавленного нами.
Вердикт: change (нужно решение по инварианту «источник неприкосновенен»).
@@ -0,0 +1,11 @@
# Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Две связанные находки о доверии к парам xt в magnet (предпосылки к F1).
F4 — split identity: v1-only magnet гибридного торрента T → задача A (catched). v2-only magnet того же T → дедуп не находит (строки хешей не связаны) → задача B. Обе активны (инвариант пер-хеш, не пер-торрент). A добавляется; qBit раскрывает infohash_v1+v2; captureInfohashes(A) пытается добавить v2 → ErrInfohashTaken (владеет B) → WARN КАЖДЫЙ тик. Add B — дубль → «Fails.» цикл → failed/qbit_add. Итог eventually-consistent, но: ложный failed, часы WARN, при худшем — обе reconcile против одного торрента → двойное распознавание/раскладка. captureInfohashes УЗНАЁТ факт (ErrInfohashTaken несёт владельца), но выбрасывает в WARN. Фикс-минимум: дедуп/дебаунс WARN; лучше — решение merge/supersede.
F5 — крафт-магнет: активная X владеет v1(X). Магнет с xt=btih:v1(X) + xt=btmh:v2(Y) где Y без активного владельца. Дедуп матчит X по v1; топ-ап пишет v2(Y) в X (гард отклоняет только хеши ЧУЖОЙ активной, у Y её нет). Теперь приём Y дедупит на X, Y никогда не качается до терминала X. CLAUDE.md трактует вывод LLM недоверенным, но ПАРУ полей magnet — доверяет. Транспорты semi-trusted (Telegram allowlist, LAN) → импакт низкий; но пересланное вредоносное сообщение трекер-бота — это ровно Telegram-поток. Фикс: топ-ап хешей на existing только когда qBit подтвердил пару (оставить топ-ап captureInfohashes, убрать из ingest attach).
Вердикт: change (решение по модели доверия/идентичности) либо задокументировать как ограничение. Связано с F1.
@@ -0,0 +1,11 @@
# .torrent поверх magnet при дедупе теряет байты — потерян upgrade-путь (F6)
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). ingest.go:85-90, download.go:252-253, спека ingest «при дедупликации байты сохраняться SHALL NOT».
Сценарий: magnet с приватного трекера → catched, source_type=magnet. Юзер понимает, что magnet не докачает метаданные (нет DHT), грузит правильный .torrent. Ingest дедупит по infohash на magnet-задачу; по спеке блоб НЕ сохраняется, source_type остаётся magnet. Worker добавляет по magnet-URL → metaDL вечно → failed/magnet_timeout через 24ч. Юзер дал именно артефакт, который бы починил, — выброшен с «уже в работе». Retry снова по magnet. Рационал самой спеки (хранить байты, «иначе на закрытых трекерах не докачать») спорит с её же правилом дедупа здесь.
Фикс: при дедупе, где входящее — torrent-байты, а existing — catched с source_type=magnet: сохранить блоб и сменить source_type в той же tx.
Вердикт: change (противоречит текущему предложению спеки, нужна дельта).
@@ -0,0 +1,17 @@
# Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Пачка независимых простых фиксов.
F7 — oversized .torrent через веб → 500 вместо 400. ingest.go:148-151 отдаёт plain fmt.Errorf «torrent too large», classifyErr (httpapi.go:753-770) → default → 500. Web MaxBytesReader пропускает MaxTorrentSize+1MiB. Фикс: sentinel-ошибка размера → 400. (Telegram ок — pre-check doc.FileSize.)
F8 — fast-path attach гонка с cancel. ingest.go:85-90,205-217: FindActiveByInfohash (без tx) вернул catched, параллельно cancel → attached() отдаёт Deduplicated=true (stale «уже в работе»), ничего не активно. Фикс: убрать ранний return, дедуп-решение только в CreateDownloadIfNoActive (уже re-check под BEGIN IMMEDIATE).
F9 — magnet parsing. magnet.go:46-51: HasPrefix «urn:btih:» регистрозависим → magnet:?xt=URN:BTIH:… отклоняется (RFC 2141: URN регистронезависим; scheme уже матчится EqualFold :91). tgbot/parse.go:11: magnet:\?[^\s]+ приклеивает хвостовую пунктуацию (точка/скобка) → xt последним → длина 41 → отказ. Фикс: case-insensitive префикс; trim хвостовых .,;:)]}>» в ParseMessage.
F10 — cap размера context из веб-формы. httpapi.go:405,412-414: multipart-бюджет на всё тело; без файла поле context ~9MiB → download.context в БД, рендер, LLM-промпты. REST 64KiB, Telegram лимит подписи — открыт только web. Фикс: cap Context в Ingest (одно место) ~16KiB с маркером.
N2 — shorten() режет по байтам не рунам (httpapi.go:713-718): кириллические source_ref-заголовки (частый случай) рвутся посреди руны → U+FFFD. Фикс: рунобезопасная обрезка.
Вердикт: все простые фиксы.
+15
View File
@@ -0,0 +1,15 @@
# Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Косметические нити.
N1 — torrent.go:114: Context() включает NoName-сентинел «-» как строку-название (name != "" проходит); ingest.parse фильтрует «-» только для source_ref (ingest.go:160-162). Одна грязная строка контекста для безымянных торрентов. Фикс: фильтровать «-» и в Context().
N3 — устаревшие комментарии. httpapi.go:574-577 и tgbot/bot.go:256-258 утверждают, что res.DownloadID может быть непуст при ошибке приёма («сбой после создания задачи, напр. qbit»). После fast-catch рефактора Ingest возвращает Result{} на КАЖДОМ пути ошибки (ingest.go:74-75,86,106-107) → корреляция всегда падает на request_id / без ключа. Фикс: поправить комментарии.
N4 — qbt.go:246 логирует «Fails.» со счётчиками, но qBittorrent не даёт причину; вместе с F2 оператор не отличит «дубль» от «битый файл». Идея: логировать хеши/первые байты для корреляции.
N5 — anacrolix bencode (v1.61.0, bencode/decode.go:17,250) аллоцирует до MaxStrLen (~128MiB) на объявленную строку до чтения — крафт-8MiB-торрент может форсить транзиентные ~128MiB аллокации при metainfo.Load. Ограничено и завершается ошибкой; на umbar приемлемо, но знать стоит. (files()-panic-guard torrent.go НЕ покрывает Load/UnmarshalInfo/HashBytes, но panic-путей там не найдено.)
Вердикт: простые фиксы/принять.
+15
View File
@@ -0,0 +1,15 @@
# Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)
**Приоритет:** низкий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Мелкие находки.
MINOR-8 — recognition claim-token отсутствует. review.go:160-177: задача в recognizing, LLM в полёте (вне w.mu); user Cancel→Relink (recognizing→cancelled→recognizing revive). Старый вызов завершается, finishRecognition re-check d.State==recognizing — true, но это НОВЫЙ claim → устаревший результат коммитится (recognizing→review); свежий прогон отбрасывает себя. Импакт низкий (входы почти идентичны), но потерянный LLM-вызов + результат приписан не той попытке. Фикс: claim-token (updated_at на момент claim, или id строки recognition).
MINOR-9 — I/O под глобальным w.mu. worker.go:677-734 (Retry: torrentByInfohash + qbt.Add под mu), review.go: ensureSourcePresent (сеть) в Relink/Rerecognize/Refine/SetType, Apply (torrentByInfohash + layouter.Apply FS I/O), Undo (FS I/O) — держат w.mu целиком. Нарушает своё же правило «медленные вызовы вне блокировки» (processCatched/recognizeOne его соблюдают). Медленный/зависший qBit → каждый клик ревью = глобальный стопор поллинга и всех команд транспортов. Корректность ок (lock даёт race-free preflight), только liveness. Для one-user home-сервера — можно осознанно waive; худший — Retry с qbt.Add под mu при зависшем qBit. Фикс если делать: probe-вне-lock + re-validate-под-lock (как processCatched).
NIT-11 — byHash индексирует и усечённый 40-hex t.Hash v2-only торрентов (worker.go:444-452). Хранение усечённых хешей отклоняется (discover.go:98-111), но lookup-мапа всё ещё ключует t.Hash → v1-хеш одной задачи теоретически == усечённый-v2 t.Hash другого торрента → матч не тому. Требует 160-бит коллизию (космологические шансы). Фикс: исключить t.Hash из мапы, когда есть infohash_v1/v2 (зеркалить torrentHashes).
NIT-12 — Retry на failed/qbit_error (missingFiles) с живым errored-торрентом: alive=true → без re-Add → downloading → следующий тик classErrored → снова failed (+ дебаунс уведомления). Честно, но user-hostile: retry выглядит сломанным, реальное лекарство (recheck/fix в qBit) не подсказано. Фикс: отклонять retry (или форсить recheck) когда живой торрент classErrored. (Связано с задачей retry/stall семантики.)
Вердикт: MINOR-8/NIT-11/NIT-12 — простые фиксы; MINOR-9 — change или осознанный waive.
@@ -0,0 +1,13 @@
# Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Два связанных бага. worker.go:677-734 (Retry), :514-525 (checkTimeouts), :578-592 (torrentAge).
MAJOR-1: Retry с живым торрентом (alive=true) не переиздаёт Add, только ActivateIfNoOtherActive→downloading; базис age=nowadded_on НЕ сбрасывается → следующий тик: stalledDL && age>StuckAfter → снова stuck (~5с). Спека state-reconciliation «Ручной повтор» требует: базис SHALL сбрасываться. Комментарий worker.go:690-691 верен лишь для re-Add ветки. Тест TestRetryReattaches не гоняет следующий тик.
MAJOR-2: stuck_after меряет ВОЗРАСТ торрента (от added_on), а не длительность простоя. Торрент, качавшийся 5ч, при мгновенном stalledDL на 1 тик → stuck с сообщением «stalled for 5h» (ложь) + EventFailed. Проход через stalledDL между пирами — норма → флап stuck↔downloading + до-часовые ложные уведомления. Спека сама противоречива («stalledDL дольше stuck_after» vs «возраст от added_on»).
Фикс: колонка retried_at и/или stalled_since (или qBit last_activity); базис = max(added_on, retried_at); простой мерить от stalled_since. Схема + миграция + сверка спеки. Покрывает также NIT-10 (фолбек added_on→created_at) и NIT-12 (retry на qbit_error мгновенно откатывается).
Вердикт: полноценный change (схема + спека). Бьёт по повседневным сценариям — retry выглядит сломанным, длинные загрузки спонтанно флапают в stuck.
@@ -0,0 +1,11 @@
# Восстановление zombie downloading при пропаже источника из qBittorrent (MAJOR-3)
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:472-477. Подтверждено чтением кода.
Сценарий: торрент удалён из qBittorrent (юзером/другим клиентом), пока задача в downloading. Poll: torrentFor промах → Warn «active download not found in qbittorrent» → continue. Каждый тик, вечно. reconcileDesync покрывает только done/target_missing/orphaned; reconcileRecovery — failed/stuck; дебаунса для этого случая НЕТ, состояние не меняется, уведомления нет, checkTimeouts требует торрент. Задача — вечный зомби, активна в UI без телеметрии; выход только Cancel/Defer. Тот же зомби при провале отката Retry (worker.go:716-729). Спека намеренно исключает активные из матрицы source×target, но «источник исчез в downloading» не владеет НИКТО — дыра спеки (сравн.: та же пропажа в completed/recognizing деградирует штатно).
Фикс: расширить дебаунс пропажи источника (SourceMissCount) на downloading → после порога downloading→deleted (или failed с distinct error_code для re-Add) + уведомление. Нужно ребро графа.
Вердикт: полноценный change. Классический «застрявшее состояние, которое никто не двигает».
@@ -0,0 +1,13 @@
# Sweep застрявших linking при рестарте + фикс persist-failure (MAJOR-4)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). review.go:284-287.
(A) Без краха: linkPlan создаёт хардлинки на FS, затем CreateFileLinks падает (транзиентная ошибка SQLite) → return без перехода → задача в linking, хардлинки на диске без file_link-строк (Undo нечего откатывать, targetPresent=false).
(B) Краш процесса между transition(StateLinking) (review.go:251) и финальным переходом → на рестарте linking не листит НИКТО (processCatched=catched, Poll=downloading, recognizePending=completed/recognizing, desync=done/tm/orphaned, recovery=failed/stuck). Задача сидит в linking вечно; выход только ручной Cancel/Defer (недискаверабельно). recognizing получил restart-healing (recognizePending), linking — нет — нарушен инвариант «у каждого нетерминального состояния есть владелец».
Фикс: на тике/старте sweep linking-задач (любая под w.mu — по построению устаревшая) → linking→review (ребро есть) с error_msg «прерванная раскладка, повтори»; при persist-failure переходить в review/failed, а не bare-return.
Вердикт: простой фикс (+ 1 спека-сценарий).
@@ -0,0 +1,11 @@
# Defer из catched → лимбо → необратимый deleted (MAJOR-6)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). review.go:465-479, worker.go:332-340, reconcile.go:251-265.
Сценарий: задача в catched (ещё не добавлена в qBit) → user Defer → deferred. processCatched больше её не видит (листит только catched) → торрент никогда не добавится. Из deferred: Apply→«нет плана», Rerecognize/Refine→ensureSourcePresent нет торрента→reconcileToReality(sourcePresent=false, targetPresent=false)→deriveState=deleted, а у deleted НОЛЬ исходящих рёбер (download.go:102) → задача необратима, хотя байты .torrent лежат в download_torrent. Также deleted семантически неверен (ничего не качалось/раскладывалось).
Фикс: исключить catched из Defer (пре-источниковое состояние, «позже» бессмысленно) ИЛИ processCatched резюмит deferred-без-recognition ИЛИ preflight «источника не было никогда» (source_added_at IS NULL и нет links) → failed/qbit_add (retriable), не deleted.
Вердикт: простой фикс (исключить catched из Defer).
@@ -0,0 +1,11 @@
# transition() глотает ошибки перед созданием хардлинков (MINOR-7)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). worker.go:595-601 (ошибка логируется, не возвращается), review.go:251-252, :196-198.
Сценарий: в Apply w.transition(StateLinking) на транзиентной ошибке БД → залогировано, выполнение продолжается → linkPlan создаёт хардлинки, пока задача ещё в review. Финальная запись linking→done оценивается как review→done — НЕ в графе → отклонена → файлы на диске, задача застряла в review со stale-планом; file_link-строки есть (re-Apply увидит StatusExists, частично самолечится), но done не достигнут, скан/уведомление не сработали. Паттерн claim-then-side-effect корректен только если claim проверяется (везде ещё — PromoteCatched, finishRecognition — гейтят; тут нет).
Фикс: transition возвращает ошибку (или mustTransition); Apply/finishRecognition прерываются до linkPlan при провале claim.
Вердикт: простой фикс.
@@ -0,0 +1,7 @@
# [идея] Сила совпадения кандидата и пересмотр распознавания/матчинга
**Приоритет:** средний
ИДЕЯ (сперва проработать). У кандидата метабазы нет метрики силы совпадения (metadata_candidate хранит provider/id/title/year/url), решение «авто vs review» — по правилу «единственный сильный матч + валидация», не по числу. Для ревью: список кандидатов нечем отсортировать/подсветить по уверенности. Идея — ввести на этапе матча силу совпадения кандидата (точное совпадение названия+года vs частичное) для сортировки и подсказки в UI. Шире — продумать сам процесс распознавания и матчинга: границы «разбор LLM / поиск в базе / сверка», что храним у кандидата, как считаем и показываем уверенность.
Связано: specs/recognition.md, ADR-2026-06-13-auto-link-requires-db-match, specs/review-ux.md.
@@ -0,0 +1,7 @@
# [идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки
**Приоритет:** средний
ИДЕЯ (проработать крайние случаи). Обычный случай — один сезон (его номер видно глазами и сверяем на ревью — под это сделана сводка сезонов). Но в редких заказах раздача сложнее: все сезоны сериала разом, пак нескольких сезонов, смешанная нумерация, вложенные папки сезонов, разнобойные имена файлов. Сейчас PlanFile.Season задаётся на каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не проработаны: как надёжно распознать, как показать на ревью, как разложить и как стыкуется со сходимостью папки и merge-докачиванием. Решить, что поддерживаем явно, а что уводим в ревью как «сложную раскладку».
Связано: specs/recognition.md, specs/jellyfin-layout.md, specs/review-ux.md, «Проблема второго сезона», «Раздачи с докачиванием».
+7
View File
@@ -0,0 +1,7 @@
# Мгновенные обновления через SSE
**Приоритет:** низкий
Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает, но с задержкой в интервал опроса и холостыми запросами. Перевести динамический контент (прогресс загрузки, смена статуса, раздача) на Server-Sent Events, чтобы обновления приходили почти мгновенно и без лишнего поллинга. Поллинг работает, поэтому это улучшение, а не блокер; SSE — один долгоживущий ответ на соединение, ложится на server-rendered UI без тяжёлого фронтенда.
Связано: specs/architecture.md → «Транспорты», specs/review-ux.md, пакет httpapi.
@@ -0,0 +1,7 @@
# Проверка свободного места перед copy-fallback
**Приоритет:** низкий
Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске. На забитом диске это упрётся в полку посреди раскладки. Перед копированием проверять доступное место и при нехватке внятно уходить в failed с понятной причиной, а не падать на полпути.
Связано: specs/architecture.md → «Раскладка файлов» (фолбэк-копирование), пакет layout.
+7
View File
@@ -0,0 +1,7 @@
# Улучшения UI: показывать матч с записью метабазы в Telegram
**Приоритет:** низкий
Web-сторона реализована: страница загрузки /download/{id} и экран ревью показывают, с какой именно записью метабазы (TMDB/TVDB/IMDb) сматчилась загрузка — провайдер, id и ссылку. Осталось довести то же в Telegram: в уведомлениях/подтверждениях показывать запись матча (название, год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота. Полный выбор источника в вебе уже реализован.
Связано: specs/review-ux.md, specs/recognition.md (матч в базе), specs/architecture.md → «Транспорты».
+7
View File
@@ -0,0 +1,7 @@
# Выбор из нескольких находок метабазы в Telegram
**Приоритет:** низкий
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
@@ -0,0 +1,7 @@
# Словарь единого языка (ubiquitous language)
**Приоритет:** средний
Свести термины домена в один глоссарий, чтобы пользователь, документация, код и агент говорили на одном языке: загрузка, раздача, распознавание, матч, кандидат, раскладка, источник/цель, хардлинк, ревью, переход состояния и т.д. — русский термин, английский идентификатор в коде, краткое определение. Сейчас наименования расходятся между спеками, UI и кодом. Глоссарий — источник истины по именам; на нём же строится агент-ревьювер наименований.
Связано: docs/conventions, specs/architecture.md, новый файл-глоссарий.
+52
View File
@@ -0,0 +1,52 @@
# Полное удаление загрузки из jellybit («единое окно», path 2)
**Приоритет:** высокий
Безопасная половина «единого окна» уже в проде: распознавание ручного удаления и
пометка рассинхрона (state-reconciliation — `target_missing`/`orphaned`/`deleted`,
undo с nlink-гардом, preflight). Осталась вторая половина — **удалять из самого
jellybit**, не идя руками в qBittorrent/Jellyfin.
Основной сценарий одного окна — «досмотрел → освободить место»: удалить и раздачу,
и файлы в библиотеке разом. Из-за хардлинков иначе место и не вернуть — файл в
`downloads/` (qBittorrent) и наша ссылка в библиотеке указывают на **один инод**,
диск освобождается только когда исчезает последняя ссылка. Значит delete обязан
снять **обе** стороны.
## Действие «Удалить» (одна загрузка)
- Снять живые хардлинки загрузки (переиспользуем undo-механику: `superseded`
пропускаем) **+** удалить раздачу с файлами из qBittorrent (новый метод в `qbt`,
`deleteTorrents` с `deleteFiles=true`).
- **Осознанно обходим** предохранитель последней копии (undo при «источник удалён,
цель — последняя копия» отказывает — `worker/review.go:499`; здесь мы наоборот
хотим снять последнюю копию).
- Обязательное **подтверждение** — это выход за инвариант «источник
неприкосновенен», не по случайному клику. Логировать как осознанное удаление
источника.
## undo vs delete (зафиксировать различие)
- **undo** — «перераспознать»: снимает только наши ссылки, раздачу в qBittorrent
сохраняет, данные бережёт (гард включён). Уже готов.
- **delete** — «убрать окончательно, освободить место»: снимает наши ссылки И
сносит раздачу+файлы, гард выключен, состояние терминальное.
## Терминальное состояние
Переиспользуем существующий `deleted` (исход тот же: и источник, и цель сняты) —
**без** нового статуса. Инициатора (`user` против reconciliation) пишем в причину/
лог перехода. В граф переходов добавить явное пользовательское ребро
`done → deleted` (сейчас `deleted` выводит только сверка с реальностью).
## Опционально (если понадобится)
Мультивыбор в списке + «удалить выбранное» — тонкая обёртка над тем же действием
для редкого случая «снести сериал целиком». Вычисляемую группу-«тайтл» и экран
состава **не** делаем — оверинжиниринг ради редкого сценария.
Оформить как OpenSpec-change (дельта `state-reconciliation`/`review` + метод `qbt`
+ ребро графа + action в UI/боте).
Связано: drafts/logical-title-model.md §5.3/§6.4, ADR-2026-06-13-hardlinks,
openspec/specs/state-reconciliation, specs/workflow.md, пакеты qbt, worker, layout.
+7
View File
@@ -0,0 +1,7 @@
# Привязка уведомлений к источнику в ботах (мульти-бот)
**Приоритет:** средний
Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней. Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся единым для всех и точкой правды по функциональности (боты — тонкие адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и идентификатор отправителя, маршрутизировать пинги по нему.
Связано: specs/review-ux.md, specs/architecture.md → «Транспорты».
+7
View File
@@ -0,0 +1,7 @@
# Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
**Приоритет:** низкий
По калибровке болей (2026-07-02) — не боль, из приоритета выпало. Сосуществование версий доступно уже сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же путь штатно уходит в review. Явный replace (undo старого хардлинка → lay нового → супересид владения путём) — отдельный change, если/когда станет болью.
Связано: «Раздачи с докачиванием», specs/jellyfin-layout.md (never-overwrite, коллизия).
+21
View File
@@ -0,0 +1,21 @@
# Внешние субтитры: пары VobSub и языковой суффикс
**Приоритет:** средний
Базовая привязка субтитр→серия для сериала уже работает: `layout.PlanFile` несёт
`Season/Episode`, а `seriesDst` именует субтитр по стему эпизода
(`internal/layout/layout.go`, код старше аудита 2026-07-03). Для фильма тоже —
субтитр именуется по базе видеофайла.
Реальные пробелы, оставшиеся от аудита:
- **Пары VobSub** `.idx`+`.sub` не спариваются — их надо переносить вместе как одну
дорожку.
- **Языковой суффикс теряется:** у `recognize.PlanFile` нет полей `Lang/Flags`, и
`toLayoutPlan` (`internal/httpapi/review.go`) не проставляет язык — субтитр ляжет
без `*.ru.srt`-суффикса, который ждёт Jellyfin.
Смоделировать язык/флаги субтитра сквозь recognize→layout и спаривание `.idx`+`.sub`;
спека recognition уже требует «внешние субтитры SHALL привязываться к видео».
Связано: openspec/specs/recognition, specs/jellyfin-layout.md, пакеты recognize, layout.
@@ -0,0 +1,13 @@
# Проблема второго сезона (сходимость папки сериала)
**Приоритет:** высокий
Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти одинаковую. Проблема не в группировке, а в сходимости папки: имя печатается заново из выхода LLM, совпадение provider_id не гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год сериала). Отдельная сущность «тайтл» НЕ вводится. Решение — правило сходимости при построении плана: при подтверждённом матче наследовать базу папки (имя+год) от живых file_link загрузок с тем же (provider, provider_id), игнорируя LLM-выход; якоря нет → папка из распознавания, как сейчас.
Шаги:
- lookup живых ссылок по (provider, provider_id) через current recognition
- наследование базы папки (имя+год) при построении плана раскладки
- рассинхрон (несколько живых папок с одним матчем) → review, не молча
- тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
Связано: drafts/logical-title-model.md §5.2, specs/recognition.md, specs/jellyfin-layout.md
+7
View File
@@ -0,0 +1,7 @@
# Современный Web-UI как PWA
**Приоритет:** низкий
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое, удобное с телефона). Текущий server-rendered UI функционален, поэтому это улучшение, а не блокер; большой объём работы.
Связано: specs/review-ux.md (веб = точные правки), пакет httpapi.
@@ -0,0 +1,7 @@
# [идея] Завершение загрузки через webhook
**Приоритет:** низкий
ИДЕЯ (решим по опыту эксплуатации). Сейчас завершение ловим поллингом qBittorrent раз в несколько секунд. Альтернатива: «Run external program on torrent completion» в qBittorrent дёргает эндпоинт jellybit. Реагирует быстрее, но связывает нас с конфигом qBittorrent.
Связано: specs/architecture.md → «Отслеживание загрузки», пакет worker.
+2 -1
View File
@@ -61,7 +61,8 @@ reject / defer / undo) — команды к `worker`:
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле (server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
`http.trusted_subnets` зарезервировано, но **пока не применяется**: `http.trusted_subnets` зарезервировано, но **пока не применяется**:
деплой только в локальную сеть без доступа из интернета, поэтому деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — [backlog.md](../backlog.md#авторизация-веб-ui-на-будущее). allowlist-middleware и авторизацию отложили — задача
[«Авторизация веб-UI»](../backlog/avtorizaciya-web-ui.md) в беклоге.
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится - **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности. всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
+12 -1
View File
@@ -13,7 +13,7 @@
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`, > `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), > `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, > `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
> `DEFAULT` убран). > `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла).
Назначение таблиц и почему так — [architecture.md](architecture.md) → Назначение таблиц и почему так — [architecture.md](architecture.md) →
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
@@ -27,6 +27,7 @@
```mermaid ```mermaid
erDiagram erDiagram
download ||--o{ download_infohash : "инфохэши (v1/v2)" download ||--o{ download_infohash : "инфохэши (v1/v2)"
download ||--o| download_torrent : "байты .torrent (1:0..1)"
download ||--o{ recognition : "распознавания" download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки" download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки" download ||--o{ override : "ручные правки"
@@ -55,6 +56,11 @@ erDiagram
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
} }
download_torrent {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
}
recognition { recognition {
TEXT id PK "ULID" TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
@@ -125,6 +131,11 @@ erDiagram
более одной активной загрузки на infohash» держат guarded-методы store более одной активной загрузки на infohash» держат guarded-методы store
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной (`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
write-транзакции — на уровне схемы он не выражается (условие на `state`). write-транзакции — на уровне схемы он не выражается (условие на `state`).
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
повторного добавления при retry, поэтому живут весь срок строки загрузки.
`ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не
удаляются).
- `download``file_link` — один источник (раздача) ко многим разложенным - `download``file_link` — один источник (раздача) ко многим разложенным
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
каждый файл раздачи попадает в `file_link` (только распознанные медиа и каждый файл раздачи попадает в `file_link` (только распознанные медиа и
+1 -1
View File
@@ -99,4 +99,4 @@ inode общий — диск не дублируется.
сезонам. сезонам.
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота. - **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка - **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
([backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)). ([задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md)).
+4 -3
View File
@@ -133,11 +133,12 @@ notes пояснения, неоднозначности
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season - Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode; 00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
любая неоднозначность → review. любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай, см. - Аниме с абсолютной нумерацией — отдельный крайний случай,
[backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией). [задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md).
## На будущее ## На будущее
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не `go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
бинарём). См. [backlog.md](../backlog.md#guessit-как-сервис-спутник-идея). бинарём). Задача [«guessit как сервис-спутник»](../backlog/guessit-sputnik.md)
в беклоге.
+2 -2
View File
@@ -119,8 +119,8 @@ Telegram = одобрить / подсказать / выбрать кандид
- **База неоднозначна** → выбор кандидата (часто чинит всё разом: пиннит - **База неоднозначна** → выбор кандидата (часто чинит всё разом: пиннит
provider-id и каноническое имя). provider-id и каноническое имя).
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с - **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
абсолютной нумерацией → веб-хелпер «absolute → S·E» (см. абсолютной нумерацией → веб-хелпер «absolute → S·E»
[backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)). ([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана. - **Не тот тип (movie↔series)** → переключатель пересобирает форму плана.
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор». - **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать - **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
+16 -2
View File
@@ -3,6 +3,7 @@ module git.vakhrushev.me/av/jellybit
go 1.26 go 1.26
require ( require (
github.com/anacrolix/torrent v1.61.0
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
@@ -14,17 +15,30 @@ require (
) )
require ( require (
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/crypto v0.44.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
+286 -10
View File
@@ -1,63 +1,339 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1:C/VViMMbR/4Ti2aXrWpKy34S05cRaVd6EvV9BFR3qJ8= github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 h1:C/VViMMbR/4Ti2aXrWpKy34S05cRaVd6EvV9BFR3qJ8=
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA= github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4/go.mod h1:H66QhXPJpUSdWschhL6u//v3ge96/qMnQ9mWp3efbxA=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+5
View File
@@ -111,6 +111,10 @@ type Worker struct {
PollInterval Duration `toml:"poll_interval"` PollInterval Duration `toml:"poll_interval"`
StuckAfter Duration `toml:"stuck_after"` StuckAfter Duration `toml:"stuck_after"`
MagnetTimeout Duration `toml:"magnet_timeout"` MagnetTimeout Duration `toml:"magnet_timeout"`
// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
CatchTimeout Duration `toml:"catch_timeout"`
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в // SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи, // qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик. // см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
@@ -208,6 +212,7 @@ func Default() *Config {
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(24 * time.Hour), MagnetTimeout: Duration(24 * time.Hour),
CatchTimeout: Duration(10 * time.Minute),
SourceMissingThreshold: 3, SourceMissingThreshold: 3,
}, },
Recognition: Recognition{AutoConfidenceThreshold: 0.85}, Recognition: Recognition{AutoConfidenceThreshold: 0.85},
+2
View File
@@ -20,6 +20,7 @@ type downloadDetailView struct {
Infohashes []string // все хеши загрузки (блок «Информация о торренте») Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string Context string
State string State string
SelfPoll bool // catched → страница сама опрашивает себя до перехода
Error string Error string
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
Note string Note string
@@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Infohashes: d.HashList(), Infohashes: d.HashList(),
Context: d.Context, Context: d.Context,
State: string(d.State), State: string(d.State),
SelfPoll: d.State == store.StateCatched,
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Note: desyncNote(d.State), Note: desyncNote(d.State),
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
+41 -10
View File
@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -25,6 +26,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/magnet" "git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/torrent"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
"git.vakhrushev.me/av/jellybit/web" "git.vakhrushev.me/av/jellybit/web"
) )
@@ -113,6 +115,9 @@ func NewRouter(d Deps) (http.Handler, error) {
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера). // Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress) r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding) r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
// имя и появившийся прогресс обновляются без перезагрузки).
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана). // Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
@@ -195,6 +200,7 @@ type downloadView struct {
Error string Error string
Terminal bool Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
Progress progressView // живой прогресс (заполняется в handleIndex из снимка) Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
@@ -391,14 +397,34 @@ func pageWindow(page, pages int) []int {
} }
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) { func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { // Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
redirectErr(w, r, "не удалось разобрать форму") // небольшой запас на прочие поля. ParseMultipartForm сперва разбирает
// urlencoded-тело (ParseForm), поэтому на обычной (не-multipart) отправке —
// напр. из curl или устаревшей страницы — вернёт ErrNotMultipart, но поля
// уже в PostForm: такую ошибку глотаем, текстовый путь не ломается.
r.Body = http.MaxBytesReader(w, r.Body, ingest.MaxTorrentSize+1<<20)
if err := r.ParseMultipartForm(ingest.MaxTorrentSize + 1<<20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
redirectErr(w, r, "не удалось разобрать форму (возможно, файл слишком большой)")
return return
} }
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
Source: r.PostForm.Get("source"), req := ingest.Request{
Context: r.PostForm.Get("context"), Source: r.FormValue("source"),
}) Context: r.FormValue("context"),
}
// Выбран .torrent-файл — приём по байтам (в приоритете над текстом).
if file, header, err := r.FormFile("torrent"); err == nil {
defer func() { _ = file.Close() }()
data, rerr := io.ReadAll(file)
if rerr != nil {
redirectErr(w, r, "не удалось прочитать .torrent-файл")
return
}
req.TorrentData = data
req.TorrentName = header.Filename // фолбек для source_ref
}
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil { if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID)) redirectErr(w, r, userErr(r, err, res.DownloadID))
return return
@@ -618,6 +644,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String, Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading, IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched,
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
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 ||
@@ -720,15 +747,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное // classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
// человекочитаемое сообщение публичного канала (без сырого err.Error() и // человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404, валидация источника // деталей реализации): ErrNotFound → 404, валидация источника
// (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409, // (magnet.ErrNotMagnet) → 400, недокачанный источник (worker.ErrNotReady) и
// прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём // конфликт состояния (worker.ErrConflict) → 409, прочее → 500. Полная ошибка
// только сообщение + корреляционный ключ. // уже в логах на доменной границе — наружу отдаём только сообщение +
// корреляционный ключ.
func classifyErr(err error) (int, string) { func classifyErr(err error) (int, string) {
switch { switch {
case errors.Is(err, store.ErrNotFound): case errors.Is(err, store.ErrNotFound):
return http.StatusNotFound, "не найдено" return http.StatusNotFound, "не найдено"
case errors.Is(err, magnet.ErrNotMagnet): case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
return http.StatusBadRequest, "некорректный источник" return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, worker.ErrNotReady):
// Источник ещё качается — actionable причина, показываем конкретно.
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
case errors.Is(err, worker.ErrConflict): case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой. // Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии" return http.StatusConflict, "действие недоступно в текущем состоянии"
+48
View File
@@ -226,6 +226,27 @@ func TestAPICommandConflict(t *testing.T) {
} }
} }
func TestAPICommandNotReady(t *testing.T) {
// Недокачанный источник (worker.ErrNotReady) → 409 с конкретным сообщением
// «торрент ещё качается», а не генерик «действие недоступно».
cmd := &fakeCommander{err: fmt.Errorf("apply: торрент ещё качается: %w", worker.ErrNotReady)}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var got map[string]any
_ = json.NewDecoder(resp.Body).Decode(&got)
if msg, _ := got["error"].(string); !strings.Contains(msg, "качается") {
t.Errorf("error = %q, want содержащее «качается»", msg)
}
}
func TestIndexRenders(t *testing.T) { func TestIndexRenders(t *testing.T) {
reader := &fakeReader{list: []store.Download{ reader := &fakeReader{list: []store.Download{
{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading}, {ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
@@ -246,6 +267,33 @@ func TestIndexRenders(t *testing.T) {
} }
} }
// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без
// перезагрузки при переходе в downloading) и человекочитаемый бейдж.
func TestCatchedCardSelfPolls(t *testing.T) {
reader := &fakeReader{get: &store.Download{
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
State: store.StateCatched,
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
s := string(body)
if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) {
t.Errorf("карточка catched без самополлинга: %s", s)
}
if !strings.Contains(s, "принято, добавляется") {
t.Errorf("нет подписи бейджа catched: %s", s)
}
}
type ingestErr string type ingestErr string
func (e ingestErr) Error() string { return string(e) } func (e ingestErr) Error() string { return string(e) }
+20
View File
@@ -96,6 +96,26 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
s.render(w, "progress", buildProgress(id, active, l, ok)) s.render(w, "progress", buildProgress(id, active, l, ok))
} }
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
// карточка уже не несёт самополлинга — цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
// живого снимка внутри buildCardView.
s.render(w, "card", s.buildCardView(*d, time.Now(), 0))
}
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). // handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) { func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
+2
View File
@@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler {
// как есть, чтобы не терять его в UI. // как есть, чтобы не терять его в UI.
func badgeLabel(state string) string { func badgeLabel(state string) string {
switch state { switch state {
case "catched":
return "🎣 принято, добавляется"
case "downloading": case "downloading":
return "⬇ качается" return "⬇ качается"
case "completed": case "completed":
+87
View File
@@ -0,0 +1,87 @@
package httpapi_test
import (
"bytes"
"mime/multipart"
"net/http"
"net/url"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// Веб-форма с выбранным .torrent-файлом → приём по байтам (TorrentData).
func TestUIAddTorrentFile(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("source", "") // источник пуст — используется файл
fw, _ := mw.CreateFormFile("torrent", "dune.torrent")
torrentBytes := []byte("d8:announce…bytes")
_, _ = fw.Write(torrentBytes)
_ = mw.Close()
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
if !bytes.Equal(ing.lastReq.TorrentData, torrentBytes) {
t.Errorf("TorrentData не проброшен в ingest: %q", ing.lastReq.TorrentData)
}
}
// Без файла — прежний текстовый путь (Source), TorrentData пуст.
func TestUIAddTextNoFile(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("source", "magnet:?xt=urn:btih:abc")
_ = mw.Close()
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
if len(ing.lastReq.TorrentData) != 0 {
t.Errorf("TorrentData должен быть пуст без файла")
}
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
t.Errorf("source не проброшен: %q", ing.lastReq.Source)
}
}
// Обычная (не-multipart) urlencoded-отправка тоже работает (ErrNotMultipart
// глотается, поля уже в PostForm) — устойчивость к curl/старой странице.
func TestUIAddUrlencoded(t *testing.T) {
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads", url.Values{
"source": {"magnet:?xt=urn:btih:abc"},
})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
}
}
+124 -113
View File
@@ -1,6 +1,9 @@
// Package ingest — use-case приёма загрузки, общий для всех транспортов // Package ingest — use-case быстрого приёма загрузки, общий для всех
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в // транспортов (HTTP, Telegram, CLI). Синхронно только парсит источник,
// qBittorrent и заводит/находит задачу в БД. // синтезирует контекст из полей ссылки, дедуплицирует и сохраняет загрузку в
// состоянии `catched`, сразу возвращая ответ. Вывод отображаемого имени
// (медленный LLM) и добавление в qBittorrent — отдельный асинхронный шаг
// worker'а (см. download-tracking).
package ingest package ingest
import ( import (
@@ -11,76 +14,46 @@ import (
"git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/magnet" "git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/torrent"
) )
// 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 — быстрый читающий дедуп-чек (до вызова LLM-namer); // FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
// авторитетная проверка — внутри CreateDownloadIfNoActive. // внутри CreateDownloadIfNoActive.
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error) FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная // CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil — // загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам). // дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) // torrentBlob (для source_type=torrent) пишется в той же транзакции только
// на ветке создания; при дедупе не пишется. Для magnet — nil.
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на // AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive. // быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
} }
// QBittorrent — нужная ingest часть клиента qBittorrent. // Service — реализация быстрого приёма.
type QBittorrent interface {
Add(ctx context.Context, ar qbt.AddRequest) error
}
// Namer выводит человекочитаемое отображаемое имя торрента из контекста.
// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается.
type Namer interface {
DeriveName(ctx context.Context, contextText, hint string) string
}
// Config — параметры добавления в qBittorrent.
type Config struct {
Category string
SavePath string
}
// Service — реализация приёма.
type Service struct { type Service struct {
store Store store Store
qbt QBittorrent
namer Namer
cfg Config
log *slog.Logger log *slog.Logger
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID string)
} }
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не // New собирает сервис приёма.
// выводится; qBittorrent оставит своё). func New(st Store, log *slog.Logger) *Service {
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service { return &Service{store: st, log: log}
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
} }
// SetFailureNotifier подключает пинг о падении приёма (до начала работы). // Request — входной запрос приёма. Источник задаётся либо строкой (magnet), либо
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn } // байтами `.torrent` (TorrentData); при непустом TorrentData он в приоритете.
// Request — входной запрос приёма.
type Request struct { type Request struct {
Source string // пока — magnet-ссылка Source string // magnet-ссылка (когда TorrentData пуст)
TorrentData []byte // байты .torrent-файла (опц.); при наличии — источник torrent
TorrentName string // имя загруженного файла (опц.); фолбек для source_ref
Context string // подсказка для распознавания (опц.) Context string // подсказка для распознавания (опц.)
} }
@@ -92,50 +65,44 @@ type Result struct {
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
} }
// Ingest принимает источник: извлекает infohash, дедуплицирует по активной // Ingest быстро принимает источник: извлекает infohash, синтезирует контекст из
// задаче, иначе заводит задачу и отдаёт источник в qBittorrent. // полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
// имени выполняет worker (см. download-tracking).
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
source := strings.TrimSpace(req.Source) src, err := s.parse(req)
info, err := magnet.Parse(source)
if err != nil { if err != nil {
// Ф1: поддержан только magnet. .torrent/url — следующий заход. return Result{}, err
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
} }
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload. // Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
// ключи корреляции к своим ext.*-записям сами.
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
ctx = logctx.With(ctx, log) ctx = logctx.With(ctx, log)
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная) // Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из // CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
// хешей источника: гибридный magnet несёт и v1, и v2. // гибридный несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil { if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
return Result{}, fmt.Errorf("ingest: lookup active: %w", err) return Result{}, fmt.Errorf("ingest: lookup active: %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 to active", "download_id", existing.ID, "state", existing.State)
return s.attached(ctx, info, existing), nil return s.attached(ctx, src, existing), nil
}
// Отображаемое имя для списка qBit — best-effort: не валит приём.
// Выводится синхронно (param rename действует только при добавлении) и
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
var rename string
if s.namer != nil {
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
} }
// Контекст распознавания дополняем фактами из полей источника (magnet-поля
// или дерево `.torrent`) — без сети. Пользовательский текст идёт первым.
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
d := &store.Download{ d := &store.Download{
SourceType: store.SourceMagnet, SourceType: src.sourceType,
SourceRef: source, SourceRef: src.sourceRef,
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI Context: mergeContext(req.Context, src.synthContext),
Context: req.Context, State: store.StateCatched,
State: store.StateDownloading,
} }
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине. // Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes) // torrentBlob непуст только для source_type=torrent — пишется в той же
// транзакции лишь на ветке создания.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
if err != nil { if err != nil {
return Result{}, fmt.Errorf("ingest: create download: %w", err) return Result{}, fmt.Errorf("ingest: create download: %w", err)
} }
@@ -146,60 +113,104 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State) log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{ return Result{
DownloadID: existing.ID, DownloadID: existing.ID,
Infohashes: info.Infohashes, Infohashes: src.infohashes,
State: existing.State, State: existing.State,
Deduplicated: true, Deduplicated: true,
}, nil }, nil
} }
id := d.ID
log = log.With("download_id", id)
ctx = logctx.With(ctx, log)
addErr := s.qbt.Add(ctx, qbt.AddRequest{ log.Info("download catched", "download_id", d.ID)
URLs: []string{source},
Category: s.cfg.Category,
SavePath: s.cfg.SavePath,
Rename: rename,
})
if addErr != nil {
// Граница доменной операции приёма: логируем исход один раз (ERROR).
// Поведение самого вызова qBittorrent уже залогировал клиент (ext.*) —
// это разные факты, не дубль.
log.Error("download accept failed", "error", addErr)
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
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)
} else if s.notifyFailed != nil {
// Это падение минует worker.transition (раздачи в qBit нет) — уведомляем
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
}
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
}
log.Info("download accepted", "category", s.cfg.Category)
return Result{ return Result{
DownloadID: id, DownloadID: d.ID,
Infohashes: info.Infohashes, Infohashes: src.infohashes,
State: store.StateDownloading, State: store.StateCatched,
}, nil }, nil
} }
// MaxTorrentSize — предел размера принимаемого `.torrent` (защита от разбухания
// БД и oversized-загрузок). Реальные торрент-файлы много меньше; крупные (много
// файлов → много piece-хешей) отсекаются здесь.
const MaxTorrentSize = 8 << 20 // 8 MiB
// parsedSource — нормализованный источник приёма (magnet или .torrent).
type parsedSource struct {
sourceType store.SourceType
sourceRef string // референс для человека/логов (magnet-URI или имя torrent)
infohashes []string // все хеши источника; v1 раньше v2
synthContext string // синтез контекста из полей источника (без сети)
torrentBlob []byte // байты .torrent (для source_type=torrent); иначе nil
}
// parse разбирает источник запроса: при непустом TorrentData — как `.torrent`
// (в приоритете), иначе — как magnet. Синтез контекста и хеши берутся из полей
// источника, без сети.
func (s *Service) parse(req Request) (parsedSource, error) {
if len(req.TorrentData) > 0 {
if len(req.TorrentData) > MaxTorrentSize {
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
}
info, err := torrent.Parse(req.TorrentData)
if err != nil {
return parsedSource{}, fmt.Errorf("ingest: parse torrent: %w", err)
}
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
// Фолбек на имя файла, если у раздачи нет содержательного имени
// (пустое или NoName-сентинел "-").
ref := strings.TrimSpace(info.DisplayName)
if ref == "" || ref == "-" {
ref = strings.TrimSpace(req.TorrentName)
}
return parsedSource{
sourceType: store.SourceTorrent,
sourceRef: ref,
infohashes: info.Infohashes,
synthContext: info.Context(),
torrentBlob: req.TorrentData,
}, nil
}
source := strings.TrimSpace(req.Source)
info, err := magnet.Parse(source)
if err != nil {
return parsedSource{}, fmt.Errorf("ingest: parse source: %w", err)
}
return parsedSource{
sourceType: store.SourceMagnet,
sourceRef: source,
infohashes: info.Infohashes,
synthContext: info.Context(),
}, nil
}
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
func mergeContext(userText, synth string) string {
parts := make([]string, 0, 2)
if s := strings.TrimSpace(userText); s != "" {
parts = append(parts, s)
}
if s := strings.TrimSpace(synth); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, "\n")
}
// attached — итог дедупа на быстром чеке: присоединились к уже активной // attached — итог дедупа на быстром чеке: присоединились к уже активной
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог // задаче и доносим ей недостающие хеши источника (гибридный magnet мог
// принести хеш, которого задача ещё не знает; guarded-путь через // принести хеш, которого задача ещё не знает; guarded-путь через
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт // CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
// хеша с другой активной задачей логируется, приём не валится. // хеша с другой активной задачей логируется, приём не валится.
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result { func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
if len(info.Infohashes) > len(existing.Infohashes) { if len(src.infohashes) > len(existing.Infohashes) {
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil { if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err) logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
} }
} }
return Result{ return Result{
DownloadID: existing.ID, DownloadID: existing.ID,
Infohashes: info.Infohashes, Infohashes: src.infohashes,
State: existing.State, State: existing.State,
Deduplicated: true, Deduplicated: true,
} }
+66 -133
View File
@@ -2,14 +2,12 @@ package ingest
import ( import (
"context" "context"
"errors"
"io" "io"
"log/slog" "log/slog"
"strings"
"testing" "testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
) )
@@ -21,28 +19,22 @@ type fakeStore struct {
active *store.Download active *store.Download
created []store.Download created []store.Download
hashes [][]string hashes [][]string
blobs [][]byte
toppedUp []string toppedUp []string
stateCalls []stateCall
}
type stateCall struct {
id string
state store.State
code string
msg string
} }
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) { func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
return f.active, nil return f.active, nil
} }
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) { func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if f.active != nil { if f.active != nil {
return f.active, nil return f.active, nil
} }
d.ID = ident.NewID() d.ID = ident.NewID()
f.created = append(f.created, *d) f.created = append(f.created, *d)
f.hashes = append(f.hashes, hashes) f.hashes = append(f.hashes, hashes)
f.blobs = append(f.blobs, torrentBlob)
return nil, nil return nil, nil
} }
@@ -52,121 +44,101 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
return nil return nil
} }
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error { func newService(st Store) *Service {
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg}) return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
return nil
} }
type fakeQbt struct { // Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
added []qbt.AddRequest // вывод имени в пути приёма не участвуют (это делает worker).
err error func TestIngestCatchesFast(t *testing.T) {
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.err != nil {
return f.err
}
f.added = append(f.added, ar)
return nil
}
// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы.
type fakeNamer struct {
name string
gotContext string
gotHint string
called bool
}
func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string {
f.called = true
f.gotContext = contextText
f.gotHint = hint
return f.name
}
func newService(st Store, qb QBittorrent) *Service {
return newServiceWithNamer(st, qb, nil)
}
func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service {
return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func TestIngestHappyPath(t *testing.T) {
fs := &fakeStore{} fs := &fakeStore{}
fq := &fakeQbt{} res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
if err != nil { if err != nil {
t.Fatalf("Ingest: %v", err) t.Fatalf("Ingest: %v", err)
} }
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash { if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
t.Errorf("infohashes = %v", res.Infohashes) t.Errorf("infohashes = %v", res.Infohashes)
} }
if res.State != store.StateDownloading || res.Deduplicated { if res.State != store.StateCatched || res.Deduplicated {
t.Errorf("res = %+v", res) t.Errorf("res = %+v", res)
} }
if len(fs.created) != 1 { if len(fs.created) != 1 {
t.Fatalf("создано задач: %d, want 1", len(fs.created)) t.Fatalf("создано задач: %d, want 1", len(fs.created))
} }
if got := fs.created[0]; got.Context != "Дюна 2" { got := fs.created[0]
t.Errorf("сохранённая задача: %+v", got) if got.State != store.StateCatched {
t.Errorf("state задачи = %q, want catched", got.State)
}
// Имя выводит worker на шаге добавления — при приёме display_name пуст.
if got.DisplayName != "" {
t.Errorf("display_name при приёме = %q, want пусто", got.DisplayName)
}
// download.Context = пользовательский текст + синтез из полей magnet
// (dn=Dune). Текст пользователя идёт первым.
if !strings.HasPrefix(got.Context, "Дюна 2") || !strings.Contains(got.Context, "Dune") {
t.Errorf("сохранённый контекст = %q", got.Context)
} }
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash { if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
t.Errorf("хеши задачи: %v", fs.hashes) t.Errorf("хеши задачи: %v", fs.hashes)
} }
if len(fq.added) != 1 { }
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
// Голый magnet без текста: download.Context синтезируется из полей ссылки
// (dn-имя + размер), приём проходит штатно.
func TestIngestMagnetOnlySynthesizesContext(t *testing.T) {
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
"&dn=Dune.Part.Two.2024.2160p&xl=2200000000"
fs := &fakeStore{}
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
t.Fatalf("Ingest: %v", err)
} }
add := fq.added[0] if len(fs.created) != 1 {
if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet { t.Fatalf("создано задач: %d, want 1", len(fs.created))
t.Errorf("URLs = %v", add.URLs)
} }
if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" { ctx := fs.created[0].Context
t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath) if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") {
t.Errorf("контекст не синтезирован из magnet: %q", ctx)
} }
} }
func TestIngestSetsDisplayName(t *testing.T) { // Заглушка-dn (rutracker-topic-*) как строка-название в контекст не попадает,
// но домен трекера — попадает (сигнал для recognition).
func TestIngestSynthDropsStubName(t *testing.T) {
const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" +
"&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet"
fs := &fakeStore{} fs := &fakeStore{}
fq := &fakeQbt{} if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
Request{Source: sampleMagnet, Context: "Дюна 2"})
if err != nil {
t.Fatalf("Ingest: %v", err) t.Fatalf("Ingest: %v", err)
} }
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" { got := fs.created[0].Context
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called) if !strings.Contains(got, "t-ru.org") {
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
} }
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" { if strings.Contains(got, "rutracker-topic") {
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)") t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
}
// То же имя сохраняется у загрузки — заголовок в веб-UI.
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
} }
} }
func TestIngestEmptyNameOmitsRename(t *testing.T) { // Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из
// dn (раскодирован) + домен трекера.
func TestIngestRealRutrackerMagnetOnly(t *testing.T) {
const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" +
"&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" +
"&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D"
fs := &fakeStore{} fs := &fakeStore{}
fq := &fakeQbt{} if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
nm := &fakeNamer{name: ""} // имя не получено
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
Request{Source: sampleMagnet}); err != nil {
t.Fatalf("Ingest: %v", err) t.Fatalf("Ingest: %v", err)
} }
if len(fq.added) != 1 || fq.added[0].Rename != "" { ctx := fs.created[0].Context
t.Errorf("rename = %q, want пусто", fq.added[0].Rename) if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
t.Errorf("download.Context не обогащён: %q", ctx)
} }
} }
func TestIngestIdempotent(t *testing.T) { func TestIngestIdempotent(t *testing.T) {
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading} existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateCatched}
fs := &fakeStore{active: existing} fs := &fakeStore{active: existing}
fq := &fakeQbt{} res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
if err != nil { if err != nil {
t.Fatalf("Ingest: %v", err) t.Fatalf("Ingest: %v", err)
} }
@@ -176,9 +148,6 @@ func TestIngestIdempotent(t *testing.T) {
if len(fs.created) != 0 { if len(fs.created) != 0 {
t.Error("не должно создаваться новой задачи") t.Error("не должно создаваться новой задачи")
} }
if len(fq.added) != 0 {
t.Error("не должно быть повторного добавления в qBittorrent")
}
} }
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши // Быстрый дедуп-путь доносит существующей задаче недостающие хеши
@@ -187,11 +156,11 @@ func TestIngestIdempotent(t *testing.T) {
func TestIngestDedupTopsUpHashes(t *testing.T) { func TestIngestDedupTopsUpHashes(t *testing.T) {
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
existing := &store.Download{ existing := &store.Download{
ID: "01hzzzexisting000000000000", State: store.StateDownloading, ID: "01hzzzexisting000000000000", State: store.StateCatched,
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}}, Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
} }
fs := &fakeStore{active: existing} fs := &fakeStore{active: existing}
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), res, err := newService(fs).Ingest(context.Background(),
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2}) Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
if err != nil { if err != nil {
t.Fatalf("Ingest: %v", err) t.Fatalf("Ingest: %v", err)
@@ -204,48 +173,12 @@ func TestIngestDedupTopsUpHashes(t *testing.T) {
} }
} }
func TestIngestQbitErrorMarksFailed(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
if err == nil {
t.Fatal("ожидалась ошибка")
}
if res.State != store.StateFailed {
t.Errorf("state = %q, want failed", res.State)
}
if len(fs.stateCalls) != 1 || fs.stateCalls[0].state != store.StateFailed {
t.Errorf("ожидался перевод в failed: %+v", fs.stateCalls)
}
}
func TestIngestQbitErrorNotifies(t *testing.T) {
fs := &fakeStore{}
fq := &fakeQbt{err: errors.New("connection refused")}
svc := newService(fs, fq)
got := make(chan string, 1)
svc.SetFailureNotifier(func(id string) { got <- id })
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
t.Fatal("ожидалась ошибка")
}
select {
case id := <-got:
if id == "" {
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{} if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
t.Fatal("ожидалась ошибка для не-magnet источника") t.Fatal("ожидалась ошибка для не-magnet источника")
} }
if len(fs.created) != 0 || len(fq.added) != 0 { if len(fs.created) != 0 {
t.Error("не должно быть ни записи, ни добавления") t.Error("не должно быть записи задачи")
} }
} }
+129
View File
@@ -0,0 +1,129 @@
package ingest
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"strings"
"testing"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// buildTorrent собирает валидные байты .torrent и ожидаемый v1-инфохэш.
func buildTorrent(t *testing.T, name, announce string) (data []byte, infohash string) {
t.Helper()
info := metainfo.Info{Name: name, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal info: %v", err)
}
sum := sha1.Sum(infoBytes)
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes(), hex.EncodeToString(sum[:])
}
func TestIngestTorrentFile(t *testing.T) {
data, wantHash := buildTorrent(t, "Dune.Part.Two.2024.mkv", "http://bt.rutracker.org/ann")
fs := &fakeStore{}
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data, Context: "мой текст"})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if res.State != store.StateCatched || res.Deduplicated {
t.Errorf("res = %+v", res)
}
if len(res.Infohashes) != 1 || res.Infohashes[0] != wantHash {
t.Errorf("infohashes = %v, want [%s]", res.Infohashes, wantHash)
}
if len(fs.created) != 1 {
t.Fatalf("создано задач: %d", len(fs.created))
}
d := fs.created[0]
if d.SourceType != store.SourceTorrent {
t.Errorf("source_type = %q, want torrent", d.SourceType)
}
if d.SourceRef != "Dune.Part.Two.2024.mkv" {
t.Errorf("source_ref = %q (имя раздачи, не URL)", d.SourceRef)
}
// Контекст: текст пользователя первым, затем синтез из полей файла.
if !strings.HasPrefix(d.Context, "мой текст") {
t.Errorf("context не с текста пользователя: %q", d.Context)
}
if !strings.Contains(d.Context, "Dune.Part.Two") || !strings.Contains(d.Context, "rutracker.org") {
t.Errorf("context без синтеза из файла: %q", d.Context)
}
// Байты сохранены в транзакции создания.
if len(fs.blobs) != 1 || !bytes.Equal(fs.blobs[0], data) {
t.Errorf("байты .torrent не переданы в CreateDownloadIfNoActive")
}
}
func TestIngestTorrentDedupNoBlob(t *testing.T) {
data, _ := buildTorrent(t, "X", "http://t/ann")
// Активная задача уже есть → дедуп; байты писаться не должны.
fs := &fakeStore{active: &store.Download{ID: "existing", State: store.StateDownloading}}
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if !res.Deduplicated || res.DownloadID != "existing" {
t.Errorf("ожидался дедуп, res = %+v", res)
}
if len(fs.created) != 0 || len(fs.blobs) != 0 {
t.Errorf("при дедупе не должно быть создания/записи байтов")
}
}
func TestIngestTorrentTooLarge(t *testing.T) {
fs := &fakeStore{}
big := make([]byte, MaxTorrentSize+1)
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
if err == nil {
t.Fatal("ожидалась ошибка превышения размера")
}
if len(fs.created) != 0 {
t.Errorf("при превышении размера задача не создаётся")
}
}
// У раздачи без имени source_ref берётся из имени файла (фолбек).
func TestIngestTorrentNameFallback(t *testing.T) {
// Info без name → BestName() == "" → фолбек на TorrentName.
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write: %v", err)
}
fs := &fakeStore{}
_, err = newService(fs).Ingest(context.Background(),
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
}
}
func TestIngestTorrentInvalid(t *testing.T) {
fs := &fakeStore{}
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: []byte("not a torrent")})
if err == nil {
t.Fatal("ожидалась ошибка разбора .torrent")
}
}
+138 -3
View File
@@ -11,6 +11,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"strconv"
"strings" "strings"
) )
@@ -20,6 +22,9 @@ type Info struct {
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2 Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
DisplayName string // dn — человекочитаемое имя, если задано DisplayName string // dn — человекочитаемое имя, если задано
Trackers []string // tr — трекеры Trackers []string // tr — трекеры
Sources []string // xs — exact source (ссылки/источники)
Keywords []string // kt — ключевые слова
ExactLength int64 // xl — размер в байтах; 0, если не задан/некорректен
} }
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой. // ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
@@ -29,9 +34,8 @@ var ErrNotMagnet = errors.New("not a magnet link")
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt // 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
// предпочитается v1. // предпочитается v1.
func Parse(raw string) (Info, error) { func Parse(raw string) (Info, error) {
raw = strings.TrimSpace(raw) u, ok := magnetURL(strings.TrimSpace(raw))
u, err := url.Parse(raw) if !ok {
if err != nil || !strings.EqualFold(u.Scheme, "magnet") {
return Info{}, ErrNotMagnet return Info{}, ErrNotMagnet
} }
vals := u.Query() vals := u.Query()
@@ -60,14 +64,41 @@ func Parse(raw string) (Info, error) {
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)") return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
} }
var xl int64
if s := strings.TrimSpace(vals.Get("xl")); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
xl = n
}
}
return Info{ return Info{
Infohash: hashes[0], Infohash: hashes[0],
Infohashes: hashes, Infohashes: hashes,
DisplayName: vals.Get("dn"), DisplayName: vals.Get("dn"),
Trackers: vals["tr"], Trackers: vals["tr"],
Sources: vals["xs"],
Keywords: vals["kt"],
ExactLength: xl,
}, nil }, nil
} }
// magnetURL разбирает строку как magnet-ссылку. Если ссылку скопировали
// целиком в процент-кодировке (magnet%3A%3Fxt%3D…, например вытащили из
// другого URL или из HTML-атрибута), пробует раскодировать её один раз и
// разобрать снова. Разовый unescape применяется ТОЛЬКО когда прямой разбор не
// дал magnet — обычную ссылку (где «+» в query значим) он не затрагивает.
func magnetURL(raw string) (*url.URL, bool) {
if u, err := url.Parse(raw); err == nil && strings.EqualFold(u.Scheme, "magnet") {
return u, true
}
if dec, err := url.QueryUnescape(raw); err == nil && dec != raw {
if u, err := url.Parse(strings.TrimSpace(dec)); err == nil && strings.EqualFold(u.Scheme, "magnet") {
return u, true
}
}
return nil, false
}
// normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex. // normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex.
func normalizeBTIH(h string) (string, error) { func normalizeBTIH(h string) (string, error) {
switch len(h) { switch len(h) {
@@ -90,6 +121,110 @@ func normalizeBTIH(h string) (string, error) {
} }
} }
// topicStubRe распознаёт dn-заглушку вида «rutracker-topic-6514485» —
// идентификатор темы трекера, а не название релиза (частый случай для голых
// magnet). Такое имя как строку-название в контекст не берём.
var topicStubRe = regexp.MustCompile(`(?i)^[\w.-]+-topic-\w+$`)
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
// Context синтезирует человекочитаемый контекст распознавания из полей самой
// ссылки (без сети): название релиза (dn, если это не заглушка-идентификатор),
// размер (xl), происхождение по домену трекера/источника (tr/xs) и ключевые
// слова (kt). Возвращает строки-факты, склеенные через "\n"; пустую строку —
// если пригодных полей нет. Результат предназначен для download.Context
// (recognition, веб-UI), но НЕ для вывода отображаемого имени.
func (i Info) Context() string {
var lines []string
if name := strings.TrimSpace(i.DisplayName); name != "" && !topicStubRe.MatchString(name) {
lines = append(lines, name)
}
if i.ExactLength > 0 {
lines = append(lines, "Размер: "+humanSize(i.ExactLength))
}
if d := originDomains(append(append([]string{}, i.Trackers...), i.Sources...)); d != "" {
lines = append(lines, "Трекер: "+d)
}
if kw := joinKeywords(i.Keywords); kw != "" {
lines = append(lines, "Ключевые слова: "+kw)
}
return strings.Join(lines, "\n")
}
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
f := float64(n)
i := -1
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
f /= unit
i++
if f < unit {
return fmt.Sprintf("≈ %.1f %s", f, u)
}
}
return fmt.Sprintf("≈ %.1f PiB", f)
}
// originDomains извлекает уникальные домены происхождения из tr/xs (только
// http(s)/udp-ссылки с хостом), убирая служебные поддомены анонса. Порядок
// сохраняется; результат — через ", ".
func originDomains(refs []string) string {
var out []string
seen := map[string]bool{}
for _, ref := range refs {
u, err := url.Parse(strings.TrimSpace(ref))
if err != nil {
continue
}
switch strings.ToLower(u.Scheme) {
case "http", "https", "udp":
default:
continue
}
host := strings.ToLower(u.Hostname())
if host == "" {
continue
}
// Срезаем служебный поддомен анонса, но лишь пока остаётся хотя бы две
// метки — иначе для хоста вида «tracker.org» получили бы голый TLD.
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
host = s
}
if seen[host] {
continue
}
seen[host] = true
out = append(out, host)
}
return strings.Join(out, ", ")
}
// joinKeywords нормализует kt: отдельные ключевые слова без пустых и дублей.
// В magnet kt разделяются «+», но url.Query() декодирует «+» в пробел ещё при
// разборе, поэтому режем по пробельным символам (strings.Fields).
func joinKeywords(kt []string) string {
var out []string
seen := map[string]bool{}
for _, v := range kt {
for kw := range strings.FieldsSeq(v) {
if seen[kw] {
continue
}
seen[kw] = true
out = append(out, kw)
}
}
return strings.Join(out, ", ")
}
// normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид // normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид
// 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в // 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в
// infohash_v2). // infohash_v2).
+153 -1
View File
@@ -1,6 +1,10 @@
package magnet package magnet
import "testing" import (
"net/url"
"strings"
"testing"
)
func TestParse(t *testing.T) { func TestParse(t *testing.T) {
tests := []struct { tests := []struct {
@@ -79,6 +83,154 @@ func TestParseErrors(t *testing.T) {
} }
} }
func TestParseExtraFields(t *testing.T) {
raw := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" +
"&xl=2200000000&xs=http%3A%2F%2Fmirror.example%2Ft.torrent&kt=dune+scifi"
got, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got.ExactLength != 2200000000 {
t.Errorf("xl = %d, want 2200000000", got.ExactLength)
}
if len(got.Sources) != 1 || got.Sources[0] != "http://mirror.example/t.torrent" {
t.Errorf("xs = %v", got.Sources)
}
if len(got.Keywords) != 1 || got.Keywords[0] != "dune scifi" {
t.Errorf("kt = %v", got.Keywords)
}
}
func TestParseInvalidXLIgnored(t *testing.T) {
for _, raw := range []string{
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=notanumber",
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=-5",
"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6",
} {
got, err := Parse(raw)
if err != nil {
t.Fatalf("Parse(%q): %v", raw, err)
}
if got.ExactLength != 0 {
t.Errorf("Parse(%q): xl = %d, want 0", raw, got.ExactLength)
}
}
}
func TestInfoContext(t *testing.T) {
tests := []struct {
name string
info Info
want []string // подстроки, которые ДОЛЖНЫ быть
notWant []string // подстроки, которых быть НЕ должно
}{
{
name: "содержательный dn",
info: Info{DisplayName: "Dune.Part.Two.2024.2160p"},
want: []string{"Dune.Part.Two.2024.2160p"},
notWant: []string{"Размер:", "Трекер:"},
},
{
name: "заглушка-dn не берётся как имя",
info: Info{DisplayName: "rutracker-topic-6514485", Trackers: []string{"http://bt.t-ru.org/ann?magnet"}},
want: []string{"Трекер: t-ru.org"},
notWant: []string{"rutracker-topic"},
},
{
name: "только размер",
info: Info{ExactLength: 2200000000},
want: []string{"Размер: ≈ 2.0 GiB"},
},
{
name: "домен из xs, служебный поддомен убран",
info: Info{Sources: []string{"udp://tracker.example.org:80/announce"}},
want: []string{"Трекер: example.org"},
notWant: []string{"tracker.example.org"},
},
{
name: "все поля вместе",
info: Info{
DisplayName: "Dune",
ExactLength: 1500,
Trackers: []string{"http://bt.t-ru.org/ann"},
Keywords: []string{"scifi dune"}, // как отдаёт url.Query() из kt=scifi+dune
},
want: []string{"Dune", "Размер:", "Трекер: t-ru.org", "Ключевые слова: scifi, dune"},
},
{
name: "пустой Info → пустой контекст",
info: Info{},
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.info.Context()
if len(tc.want) == 0 && got != "" {
t.Errorf("Context() = %q, want пусто", got)
}
for _, w := range tc.want {
if !strings.Contains(got, w) {
t.Errorf("Context() = %q, want содержит %q", got, w)
}
}
for _, nw := range tc.notWant {
if strings.Contains(got, nw) {
t.Errorf("Context() = %q, НЕ должно содержать %q", got, nw)
}
}
})
}
}
// Реальная ссылка с рутрекера: dn несёт полный релиз-заголовок в
// процент-кодировке (кириллица + латиница, сезон/серии/год/жанры/качество),
// tr — announce-хост bt4.t-ru.org. Проверяем полный путь до Context().
func TestParseRealRutrackerLink(t *testing.T) {
const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" +
"&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" +
"&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%2F%20%D0%A1%D0%B5%D1%80%D0%B8%D0%B8%3A%201-10%20%D0%B8%D0%B7%2010%20(%D0%A5%D0%B8%D1%80%D0%BE%20%D0%9C%D1%83%D1%80%D0%B0%D0%B9)%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20%D0%A3%D0%B6%D0%B0%D1%81%D1%8B%2C%20%D0%B4%D1%80%D0%B0%D0%BC%D0%B0%2C%20%D0%BA%D0%BE%D0%BC%D0%B5%D0%B4%D0%B8%D1%8F%2C%20WEB-DL%201080p%5D%207%20x%20MVO"
got, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got.Infohash != "baca24e18c7382a9e9a44132c8d7db86c4d319c2" {
t.Errorf("infohash = %q", got.Infohash)
}
// dn раскодирован url.Query() — без ручного urldecode.
const wantName = "Бухта вдов / Widow's Bay / Сезон: 1 / Серии: 1-10 из 10 " +
"(Хиро Мурай) [2026, США, Ужасы, драма, комедия, WEB-DL 1080p] 7 x MVO"
if got.DisplayName != wantName {
t.Errorf("dn = %q", got.DisplayName)
}
ctx := got.Context()
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "2026") {
t.Errorf("Context() без релиз-данных: %q", ctx)
}
if !strings.Contains(ctx, "Трекер: t-ru.org") {
t.Errorf("Context() без домена трекера (bt4. должен срезаться): %q", ctx)
}
if strings.Contains(ctx, "bt4.") {
t.Errorf("служебный поддомен не срезан: %q", ctx)
}
}
// Ссылку могли скопировать целиком в процент-кодировке (например вытащили из
// другого URL) — Parse должен раскодировать её один раз и разобрать.
func TestParseDoubleEncoded(t *testing.T) {
const inner = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&dn=Dune"
got, err := Parse(url.QueryEscape(inner))
if err != nil {
t.Fatalf("Parse(double-encoded): %v", err)
}
if got.Infohash != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" {
t.Errorf("infohash = %q", got.Infohash)
}
if got.DisplayName != "Dune" {
t.Errorf("dn = %q", got.DisplayName)
}
}
func TestParseNormalisesCase(t *testing.T) { func TestParseNormalisesCase(t *testing.T) {
lower := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" lower := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"
upper := "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" upper := "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6"
+157 -2
View File
@@ -20,6 +20,7 @@ import (
type State string type State string
const ( const (
StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent
StateDownloading State = "downloading" StateDownloading State = "downloading"
StateCompleted State = "completed" StateCompleted State = "completed"
StateRecognizing State = "recognizing" // Ф2 StateRecognizing State = "recognizing" // Ф2
@@ -63,6 +64,71 @@ func (s State) IsTerminal() bool {
return slices.Contains(terminalStates, s) return slices.Contains(terminalStates, s)
} }
// allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
// (прямой путь download-tracking, state-reconciliation, review). Выведен построчно
// из кода воркера — таблица в change `state-transition-graph`/design.md.
//
// Правила:
// - Самопереход (from == to, идемпотентная переустановка того же состояния —
// напр. повторная запись error_msg, Defer на уже deferred) разрешён ВСЕГДА и
// здесь НЕ перечисляется (его добавляет invertTransitions).
// - Ребро может присутствовать здесь, но всё равно требовать revive-путь
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния
// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не
// ручной аккуратностью.
//
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
// setState отклонит переход (0 строк UPDATE → ошибка).
var allowedTransitions = map[State][]State{
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted},
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
StateFailed: {StateDownloading, StateCompleted},
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted},
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted},
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted},
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
}
// transitionSources — обратное отображение (to → множество легальных from),
// построенное из allowedTransitions один раз при инициализации пакета. Сам to
// всегда входит в своё множество (самопереход). Основа предиката setState
// `state IN (...)`.
var transitionSources = invertTransitions(allowedTransitions)
// invertTransitions переворачивает граф from→to в to→from, добавляя каждому
// состоянию его самого (самопереход всегда легален).
func invertTransitions(fwd map[State][]State) map[State][]State {
into := make(map[State][]State, len(fwd))
ensureSelf := func(s State) {
if !slices.Contains(into[s], s) {
into[s] = append(into[s], s)
}
}
for from, tos := range fwd {
ensureSelf(from)
for _, to := range tos {
ensureSelf(to)
if !slices.Contains(into[to], from) {
into[to] = append(into[to], from)
}
}
}
return into
}
// SourceType — вид источника загрузки. // SourceType — вид источника загрузки.
type SourceType string type SourceType string
@@ -180,7 +246,12 @@ func NullString(s string) sql.NullString {
// infohash» и заводит загрузку: если активная задача с любым из hashes уже // infohash» и заводит загрузку: если активная задача с любым из hashes уже
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым // есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются. // ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) { //
// torrentBlob (если непуст) — исходные байты `.torrent`; пишутся в
// download_torrent В ТОЙ ЖЕ транзакции только на ветке создания (для
// source_type=torrent, чтобы воркер добавил раздачу файлом). При дедупе байты
// не пишутся. Для magnet/url — nil.
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string, torrentBlob []byte) (*Download, error) {
norm := normalizeHashes(hashes) norm := normalizeHashes(hashes)
if len(norm) == 0 { if len(norm) == 0 {
return nil, fmt.Errorf("create download: no infohash") return nil, fmt.Errorf("create download: no infohash")
@@ -235,12 +306,35 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
} }
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)}) d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
} }
if len(torrentBlob) > 0 {
if _, err := tx.ExecContext(ctx,
`INSERT INTO download_torrent (download_id, data) VALUES (?, ?)`,
d.ID, torrentBlob); err != nil {
return nil, fmt.Errorf("insert download torrent: %w", err)
}
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("create download: commit: %w", err) return nil, fmt.Errorf("create download: commit: %w", err)
} }
return nil, nil return nil, nil
} }
// GetTorrentData возвращает сохранённые байты `.torrent` загрузки (для
// добавления раздачи файлом воркером). ErrNotFound — если байтов нет (не
// torrent-источник или запись отсутствует).
func (s *Store) GetTorrentData(ctx context.Context, downloadID string) ([]byte, error) {
var data []byte
err := s.DB.QueryRowxContext(ctx,
`SELECT data FROM download_torrent WHERE download_id = ?`, downloadID).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("torrent data for %s: %w", downloadID, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get torrent data: %w", err)
}
return data, nil
}
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние // ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет, // (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и // что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
@@ -503,10 +597,43 @@ func (s *Store) SetDownloadState(ctx context.Context, id string, state State, er
return setState(ctx, s.DB, id, state, errCode, errMsg, false) return setState(ctx, s.DB, id, state, errCode, errMsg, false)
} }
// PromoteCatched переводит пойманную загрузку catched → downloading, попутно
// записывая выведенное отображаемое имя. Ребро catched → downloading объявлено в
// allowedTransitions; setState этот путь не проходит намеренно — собственный гард
// `state = 'catched'` жёстче (фиксирует ровно from=catched) и служит ре-валидацией:
// если загрузку успели отменить (catched → cancelled) во время вывода имени/
// добавления вне блокировки переходов, UPDATE не заденет ни строки и вернёт ошибку,
// а переход не применится. Пустое имя допустимо (rename не задавали) — тогда
// display_name так и остаётся пустым.
func (s *Store) PromoteCatched(ctx context.Context, id, displayName string) error {
res, err := s.DB.ExecContext(ctx, `
UPDATE download
SET state = ?, display_name = ?, updated_at = ?
WHERE id = ? AND state = ?`,
string(StateDownloading), displayName, FormatTime(Now()), id, string(StateCatched))
if err != nil {
return fmt.Errorf("promote catched %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("promote catched %s: %w", id, err)
}
if n == 0 {
return fmt.Errorf("promote catched %s: not in catched (already added or cancelled)", id)
}
return nil
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда // setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён; // (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу. // иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error { func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
sources := transitionSources[state]
if len(sources) == 0 {
// Целевое состояние не объявлено в графе переходов — fail-closed (это
// программная ошибка: новая State без ребра). Тест графа ловит на этапе CI.
return fmt.Errorf("set download %s state %q: target not declared in transition graph", id, state)
}
q := ` q := `
UPDATE download UPDATE download
SET state = ?, SET state = ?,
@@ -515,6 +642,10 @@ SET state = ?,
updated_at = ? updated_at = ?
WHERE id = ?` WHERE id = ?`
args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id} args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id}
// Гейт графа: переход применяется, только если текущее состояние — легальный
// источник для state (объявленное ребро или самопереход from == to). Аддитивен
// к гарду терминальности ниже и его НЕ ослабляет.
q += ` AND state IN (` + placeholders(&args, sources) + `)`
if !reviveOK && !state.IsTerminal() { if !reviveOK && !state.IsTerminal() {
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)` q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
} }
@@ -527,11 +658,35 @@ WHERE id = ?`
return fmt.Errorf("set download %s state %q: %w", id, state, err) return fmt.Errorf("set download %s state %q: %w", id, state, err)
} }
if n == 0 { if n == 0 {
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state) return setStateRejected(ctx, e, id, state)
} }
return nil return nil
} }
// setStateRejected формирует точную ошибку отклонённого перехода (0 строк UPDATE):
// читает текущее состояние и различает «не найдено» / «нелегальное ребро» /
// «терминал без revive». Только путь ошибки (редкий), поэтому доп. чтение дёшево.
func setStateRejected(ctx context.Context, e sqlx.ExecerContext, id string, state State) error {
q, ok := e.(sqlx.QueryerContext)
if !ok {
return fmt.Errorf("set download %s state %q: rejected (not found, illegal transition, or terminal without revive)", id, state)
}
var cur State
err := sqlx.GetContext(ctx, q, &cur, `SELECT state FROM download WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("set download %s state %q: %w", id, state, ErrNotFound)
}
if err != nil {
return fmt.Errorf("set download %s state %q: rejected, current state unreadable: %w", id, state, err)
}
if slices.Contains(transitionSources[state], cur) {
// Ребро cur → state легально — значит зарубил гард терминальности.
return fmt.Errorf("set download %s: %s → %s rejected: terminal revive requires ActivateIfNoOtherActive",
id, cur, state)
}
return fmt.Errorf("set download %s: illegal transition %s → %s (not in transition graph)", id, cur, state)
}
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки). // SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
// Состояние не трогает — это отдельная от перехода фоновая отметка. // Состояние не трогает — это отдельная от перехода фоновая отметка.
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error { func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
+83 -22
View File
@@ -27,12 +27,70 @@ func newDownloading() *Download {
} }
} }
func newCatched() *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:test",
Context: "ctx",
State: StateCatched,
}
}
// catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
d1 := newCatched()
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}, nil); err != nil || existing != nil {
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
}
// Повторный приём того же хеша → дедуп на активную catched-задачу.
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}, nil)
if err != nil {
t.Fatalf("повторный приём: %v", err)
}
if existing == nil || existing.ID != d1.ID {
t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing)
}
}
func TestPromoteCatched(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
d := newCatched()
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
t.Fatalf("create: %v", err)
}
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
t.Fatalf("promote: %v", err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.State != StateDownloading {
t.Errorf("state = %q, want downloading", got.State)
}
if got.DisplayName != "Дюна (2024)" {
t.Errorf("display_name = %q", got.DisplayName)
}
// Повторный promote (уже не catched) — отклоняется гардом state='catched'.
if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil {
t.Error("ожидалась ошибка promote для не-catched задачи")
}
}
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на // mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста. // существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string { func mustCreate(t *testing.T, st *Store, infohash string) string {
t.Helper() t.Helper()
d := newDownloading() d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}) existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}, nil)
if err != nil { if err != nil {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
@@ -42,6 +100,19 @@ func mustCreate(t *testing.T, st *Store, infohash string) string {
return d.ID return d.ID
} }
// forceState принудительно проставляет состояние прямым UPDATE в обход гейта
// графа переходов — для подготовки фикстур, где проверяется поведение в заданном
// состоянии, а не путь его достижения. Реальные переходы идут через
// SetDownloadState/ActivateIfNoOtherActive (их легальность проверяет
// transition_test.go).
func forceState(t *testing.T, st *Store, id string, state State) {
t.Helper()
if _, err := st.DB.ExecContext(context.Background(),
`UPDATE download SET state = ? WHERE id = ?`, string(state), id); err != nil {
t.Fatalf("force state %s: %v", state, err)
}
}
func TestCreateAndGetDownload(t *testing.T) { func TestCreateAndGetDownload(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
@@ -108,9 +179,7 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
const ih = "3333333333333333333333333333333333333333" const ih = "3333333333333333333333333333333333333333"
id := mustCreate(t, store, ih) id := mustCreate(t, store, ih)
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil { forceState(t, store, id, st)
t.Fatal(err)
}
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil { if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err) t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
} }
@@ -160,7 +229,7 @@ func TestActiveDuplicateDeduplicated(t *testing.T) {
id := mustCreate(t, st, ih) id := mustCreate(t, st, ih)
d := newDownloading() d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}) existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -185,7 +254,7 @@ func TestDedupByAnyHash(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}) existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -233,9 +302,7 @@ func TestActivateIfNoOtherActive(t *testing.T) {
} }
// Владелец завершился → активация проходит. // Владелец завершился → активация проходит.
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil { forceState(t, st, id2, StateDone)
t.Fatal(err)
}
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil { if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
t.Fatalf("активация после ухода владельца: %v", err) t.Fatalf("активация после ухода владельца: %v", err)
} }
@@ -255,7 +322,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
id := mustCreate(t, st, v1) id := mustCreate(t, st, v1)
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2. // Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}) existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -266,7 +333,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes) t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
} }
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу. // Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}) byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -296,9 +363,7 @@ func TestAddInfohashesGuard(t *testing.T) {
} }
// Хеш терминального владельца дописывается свободно. // Хеш терминального владельца дописывается свободно.
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil { forceState(t, st, a, StateDone)
t.Fatal(err)
}
b := mustCreate(t, st, "eeee333333333333333333333333333333333333") b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil { if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err) t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
@@ -342,16 +407,14 @@ func TestSetDownloadStateRejectsRevive(t *testing.T) {
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil { if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Терминал→активное обычным SetDownloadState отклоняется (revive-гард): ребро
// failed → downloading в графе есть, но проходит только через гард владения.
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil { if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
t.Fatal("терминал→активное мимо гарда должно отклоняться") t.Fatal("терминал→активное мимо гарда должно отклоняться")
} }
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed { if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
t.Fatalf("state = %s, want failed (без изменений)", d.State) t.Fatalf("state = %s, want failed (без изменений)", d.State)
} }
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
t.Fatalf("терминал→терминал должен проходить: %v", err)
}
// Штатный путь оживления работает. // Штатный путь оживления работает.
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil { if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
t.Fatalf("оживление через гард: %v", err) t.Fatalf("оживление через гард: %v", err)
@@ -374,7 +437,7 @@ func TestConcurrentCreateDedup(t *testing.T) {
go func() { go func() {
defer wg.Done() defer wg.Done()
d := newDownloading() d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}) existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
if err != nil { if err != nil {
errs <- err errs <- err
return return
@@ -414,9 +477,7 @@ func TestExistsByInfohash(t *testing.T) {
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err) t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
} }
id := mustCreate(t, st, ih) id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil { forceState(t, st, id, StateDone)
t.Fatal(err)
}
// Exists видит и терминальные (в отличие от FindActive). // Exists видит и терминальные (в отличие от FindActive).
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok { if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err) t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
+1 -1
View File
@@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State {
case GroupReview: case GroupReview:
return []State{StateReview, StateDeferred} return []State{StateReview, StateDeferred}
case GroupActive: case GroupActive:
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking} return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
case GroupDone: case GroupDone:
return []State{StateDone} return []State{StateDone}
case GroupProblem: case GroupProblem:
+2 -4
View File
@@ -16,7 +16,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
ctx := context.Background() ctx := context.Background()
d := newDownloading() d := newDownloading()
d.DisplayName = display d.DisplayName = display
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}) existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}, nil)
if err != nil { if err != nil {
t.Fatalf("create #%d: %v", n, err) t.Fatalf("create #%d: %v", n, err)
} }
@@ -24,9 +24,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
t.Fatalf("create #%d: unexpected dedup", n) t.Fatalf("create #%d: unexpected dedup", n)
} }
if state != StateDownloading { if state != StateDownloading {
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil { forceState(t, st, d.ID, state)
t.Fatalf("set state #%d: %v", n, err)
}
} }
return d.ID return d.ID
} }
@@ -0,0 +1,16 @@
-- +goose Up
-- Байты исходного `.torrent`-файла, привязанные к загрузке. Нужны воркеру на
-- шаге добавления в qBittorrent (быстрый приём лишь сохраняет `catched`):
-- torrent добавляется файлом, а не magnet-хешем — иначе на закрытых трекерах и
-- без DHT метаданные не докачаются. Отдельная таблица, а не колонка на
-- download: блоб не тянется в выборки списка/детали (SELECT * по download).
-- Байты живут весь срок строки загрузки (нужны для retry повторного добавления);
-- ON DELETE CASCADE — страховка на будущий delete-путь (сейчас загрузки не
-- удаляются).
CREATE TABLE download_torrent (
download_id TEXT PRIMARY KEY REFERENCES download (id) ON DELETE CASCADE,
data BLOB NOT NULL
);
-- +goose Down
DROP TABLE download_torrent;
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"context"
"errors"
"testing"
)
// Запись байтов .torrent на ветке создания и чтение их воркером.
func TestTorrentBlobWriteRead(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
blob := []byte("d8:announce…fake torrent bytes")
d := &Download{SourceType: SourceTorrent, SourceRef: "Some.Release", State: StateCatched}
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, blob)
if err != nil || existing != nil {
t.Fatalf("create: err=%v existing=%v", err, existing)
}
got, err := st.GetTorrentData(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if string(got) != string(blob) {
t.Errorf("blob = %q, want %q", got, blob)
}
}
// При дедупе на активную задачу байты не пишутся (второй приём того же хеша).
func TestTorrentBlobNotWrittenOnDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
first := &Download{SourceType: SourceTorrent, SourceRef: "A", State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, first, []string{hashN(2)}, []byte("first")); err != nil {
t.Fatalf("create first: %v", err)
}
// Второй приём того же инфохэша — дедуп; его байты писаться не должны.
second := &Download{SourceType: SourceTorrent, SourceRef: "B", State: StateCatched}
existing, err := st.CreateDownloadIfNoActive(ctx, second, []string{hashN(2)}, []byte("second"))
if err != nil {
t.Fatalf("create second: %v", err)
}
if existing == nil || existing.ID != first.ID {
t.Fatalf("ожидался дедуп на первую задачу, got %v", existing)
}
got, err := st.GetTorrentData(ctx, first.ID)
if err != nil {
t.Fatalf("get first: %v", err)
}
if string(got) != "first" {
t.Errorf("байты первой задачи перезаписаны: %q", got)
}
}
// Нет байтов (magnet-источник) → ErrNotFound.
func TestTorrentBlobMissing(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + hashN(3), State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(3)}, nil); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := st.GetTorrentData(ctx, d.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("ожидался ErrNotFound, got %v", err)
}
}
+238
View File
@@ -0,0 +1,238 @@
package store
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"testing"
)
// allStates — полный перечень состояний машины (для проверки хорошей
// сформированности графа). Держим локально в тесте: если добавится новое
// состояние, тест напомнит внести его сюда и в граф.
var allStates = []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
StateFailed, StateCancelled, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
// seedState заводит загрузку и приводит её к нужному состоянию кратчайшим
// доверенным путём (в обход гейта графа — через прямой UPDATE), чтобы тест
// проверял именно проверяемый переход, а не путь подготовки.
func seedState(t *testing.T, st *Store, ih string, state State) string {
t.Helper()
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
t.Fatalf("seed create: %v", err)
}
if state != StateCatched {
if _, err := st.DB.ExecContext(ctx,
`UPDATE download SET state = ? WHERE id = ?`, string(state), d.ID); err != nil {
t.Fatalf("seed set state %s: %v", state, err)
}
}
return d.ID
}
func stateOf(t *testing.T, st *Store, id string) State {
t.Helper()
d, err := st.GetDownload(context.Background(), id)
if err != nil {
t.Fatalf("get: %v", err)
}
return d.State
}
// Граф хорошо сформирован: все состояния из ключей и значений — известные, и
// ни один список не перечисляет сам ключ (петли не объявляются явно —
// самопереход добавляет invertTransitions).
func TestTransitionGraphWellFormed(t *testing.T) {
known := func(s State) bool { return slices.Contains(allStates, s) }
for from, tos := range allowedTransitions {
if !known(from) {
t.Errorf("неизвестное состояние-ключ: %q", from)
}
for _, to := range tos {
if !known(to) {
t.Errorf("%s → неизвестное состояние %q", from, to)
}
if to == from {
t.Errorf("%s: петля перечислена явно (самопереход неявен)", from)
}
}
}
// Каждое состояние присутствует в графе как ключ — иначе setState fail-closed
// отклонит переход в него.
for _, s := range allStates {
if _, ok := allowedTransitions[s]; !ok {
t.Errorf("состояние %q отсутствует среди ключей графа", s)
}
}
}
// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель
// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred —
// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после
// краха процесса).
func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
for _, s := range allStates {
if s.IsTerminal() {
continue
}
if !slices.Contains(transitionSources[StateCancelled], s) {
t.Errorf("%s → cancelled не легально (Cancel допускает любое не-терминальное)", s)
}
if s == StateDeferred {
continue // deferred → deferred покрыт самопереходом
}
if !slices.Contains(transitionSources[StateDeferred], s) {
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s)
}
}
}
// Объявленные не-revive рёбра проходят через SetDownloadState.
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateDownloading, StateCompleted},
{StateDownloading, StateStuck},
{StateCompleted, StateRecognizing},
{StateRecognizing, StateReview},
{StateRecognizing, StateLinking},
{StateReview, StateLinking},
{StateReview, StateRecognizing},
{StateLinking, StateDone},
{StateLinking, StateReview},
{StateDone, StateReverted},
{StateStuck, StateCancelled},
{StateReview, StateDeferred},
}
for i, e := range edges {
st := newTestStore(t)
ih := infohashN(i)
id := seedState(t, st, ih, e.from)
if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil {
t.Errorf("легальное ребро %s → %s отклонено: %v", e.from, e.to, err)
continue
}
if got := stateOf(t, st, id); got != e.to {
t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got)
}
}
}
// Preflight-рёбра reconcileToReality при пропавшем источнике: из ревью/
// терминальных состояний в orphaned/deleted проходят через SetDownloadState
// (цель терминальна → гард терминальности не мешает).
func TestPreflightDesyncEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateReview, StateOrphaned},
{StateReview, StateDeleted},
{StateDeferred, StateOrphaned},
{StateDeferred, StateDeleted},
{StateReverted, StateOrphaned},
{StateReverted, StateDeleted},
{StateCancelled, StateOrphaned},
{StateCancelled, StateDeleted},
}
for i, e := range edges {
st := newTestStore(t)
id := seedState(t, st, infohashN(i), e.from)
if err := st.SetDownloadState(context.Background(), id, e.to, "", ""); err != nil {
t.Errorf("preflight-ребро %s → %s отклонено: %v", e.from, e.to, err)
continue
}
if got := stateOf(t, st, id); got != e.to {
t.Errorf("%s → %s: состояние стало %q", e.from, e.to, got)
}
}
}
// Необъявленные рёбра отклоняются, состояние не меняется.
func TestSetStateRejectsUndeclaredEdges(t *testing.T) {
edges := []struct{ from, to State }{
{StateReview, StateDone},
{StateDownloading, StateDone},
{StateCompleted, StateLinking},
{StateDownloading, StateReview},
}
for i, e := range edges {
st := newTestStore(t)
id := seedState(t, st, infohashN(i), e.from)
err := st.SetDownloadState(context.Background(), id, e.to, "", "")
if err == nil {
t.Errorf("нелегальное ребро %s → %s прошло", e.from, e.to)
continue
}
if !strings.Contains(err.Error(), "illegal transition") {
t.Errorf("%s → %s: ожидалось 'illegal transition', got: %v", e.from, e.to, err)
}
if got := stateOf(t, st, id); got != e.from {
t.Errorf("%s → %s отклонён, но состояние стало %q", e.from, e.to, got)
}
}
}
// Самопереход (идемпотентная переустановка того же состояния) разрешён.
func TestSelfTransitionAllowed(t *testing.T) {
st := newTestStore(t)
id := seedState(t, st, infohashN(0), StateDeferred)
if err := st.SetDownloadState(context.Background(), id, StateDeferred, "", ""); err != nil {
t.Fatalf("самопереход deferred → deferred отклонён: %v", err)
}
if got := stateOf(t, st, id); got != StateDeferred {
t.Errorf("состояние стало %q", got)
}
}
// Ребро из терминального состояния в активное проходит только revive-путём
// (ActivateIfNoOtherActive), но не обычным SetDownloadState.
func TestTerminalReviveOnlyViaActivate(t *testing.T) {
ctx := context.Background()
// SetDownloadState (не-revive): failed → downloading отклоняется гардом
// терминальности, ошибка указывает на revive.
st := newTestStore(t)
id := seedState(t, st, infohashN(0), StateFailed)
err := st.SetDownloadState(ctx, id, StateDownloading, "", "")
if err == nil {
t.Fatal("failed → downloading через SetDownloadState прошло")
}
if !strings.Contains(err.Error(), "terminal revive") {
t.Errorf("ожидалось 'terminal revive', got: %v", err)
}
if got := stateOf(t, st, id); got != StateFailed {
t.Errorf("состояние изменилось на %q", got)
}
// ActivateIfNoOtherActive (revive): тот же переход при свободном infohash
// проходит.
st2 := newTestStore(t)
id2 := seedState(t, st2, infohashN(1), StateFailed)
if err := st2.ActivateIfNoOtherActive(ctx, id2, StateDownloading, "", ""); err != nil {
t.Fatalf("revive failed → downloading отклонён: %v", err)
}
if got := stateOf(t, st2, id2); got != StateDownloading {
t.Errorf("revive: состояние стало %q, want downloading", got)
}
}
// Отсутствующая загрузка → ErrNotFound (гейт графа не маскирует «не найдено»).
func TestSetStateNotFound(t *testing.T) {
st := newTestStore(t)
err := st.SetDownloadState(context.Background(), "01hnonexistentnonexistent", StateCompleted, "", "")
if !errors.Is(err, ErrNotFound) {
t.Errorf("ожидался ErrNotFound, got: %v", err)
}
}
// infohashN — детерминированный 40-hex инфохэш по индексу (уникальность между
// подтестами без общего состояния). Цифры и 'a'-паддинг — валидный hex.
func infohashN(n int) string {
s := strconv.Itoa(n)
return strings.Repeat("a", 40-len(s)) + s
}
+121 -7
View File
@@ -2,10 +2,15 @@ package tgbot
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -21,6 +26,9 @@ type teleAPI interface {
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
StopReceivingUpdates() StopReceivingUpdates()
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
// присланного .torrent-документа).
GetFileDirectURL(fileID string) (string, error)
} }
// Ingestor принимает загрузку (ingest.Service). // Ingestor принимает загрузку (ingest.Service).
@@ -56,6 +64,8 @@ type Bot struct {
mu sync.Mutex // защищает pending mu sync.Mutex // защищает pending
pending map[int64]string // chatID → downloadID, ждущий подсказку pending map[int64]string // chatID → downloadID, ждущий подсказку
httpClient *http.Client // для скачивания .torrent-документов Telegram
} }
// New собирает бота поверх клиента Telegram. // New собирает бота поверх клиента Telegram.
@@ -72,6 +82,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
webBase: strings.TrimRight(cfg.WebBaseURL, "/"), webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log, log: log,
pending: map[int64]string{}, pending: map[int64]string{},
httpClient: &http.Client{Timeout: 30 * time.Second},
} }
} }
@@ -121,6 +132,15 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, "Доступ запрещён.", nil) b.send(m.Chat.ID, "Доступ запрещён.", nil)
return return
} }
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
// подписи (m.Caption).
if m.Document != nil {
b.handleDocument(ctx, m)
return
}
text := strings.TrimSpace(m.Text) text := strings.TrimSpace(m.Text)
// Ждём подсказку для перераспознавания? // Ждём подсказку для перераспознавания?
@@ -140,25 +160,113 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
source, context, ok := ParseMessage(text) source, context, ok := ParseMessage(text)
if !ok { if !ok {
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil) b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
return return
} }
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context}) b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{Source: source, Context: context})
}
// handleDocument принимает присланный .torrent-документ: проверяет тип/размер,
// скачивает байты через Bot API и подаёт в приём (подпись — контекстом).
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
doc := m.Document
if !isTorrentDoc(doc) {
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
return
}
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
return
}
data, err := b.downloadFile(ctx, doc.FileID)
if err != nil {
b.log.Warn("telegram document download failed", "error", err)
b.send(m.Chat.ID, "Не удалось скачать файл из Telegram, попробуйте ещё раз.", nil)
return
}
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{
TorrentData: data,
TorrentName: doc.FileName, // фолбек для source_ref, если у раздачи нет имени
Context: m.Caption,
})
}
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
// бота), оставляя только первопричину — защита от утечки секрета в логи.
func stripURL(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
func isTorrentDoc(doc *tgbotapi.Document) bool {
if doc == nil {
return false
}
if doc.MimeType == "application/x-bittorrent" {
return true
}
return strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent")
}
// downloadFile скачивает файл Telegram по file_id (прямой URL + HTTP GET),
// ограничивая объём MaxTorrentSize.
//
// ВАЖНО: прямой URL файла Telegram содержит токен бота
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
fileURL, err := b.api.GetFileDirectURL(fileID)
if err != nil {
return nil, fmt.Errorf("file url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
}
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("telegram file GET: status %d", resp.StatusCode)
}
// LimitReader на 1 байт больше лимита — чтобы отличить «ровно лимит» от
// «больше лимита».
data, err := io.ReadAll(io.LimitReader(resp.Body, ingest.MaxTorrentSize+1))
if err != nil {
return nil, err
}
if len(data) > ingest.MaxTorrentSize {
return nil, fmt.Errorf("telegram file exceeds %d bytes", ingest.MaxTorrentSize)
}
return data, nil
}
// ingestAndReply принимает загрузку и отвечает пользователю единообразно для
// всех источников (текст/magnet и .torrent-документ).
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
res, err := b.ingestor.Ingest(ctx, req)
if err != nil { if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit); // res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
// при раннем разборе источника id ещё нет — даём дружелюбный текст без // при раннем разборе источника id ещё нет — даём дружелюбный текст без
// него (детали всё равно в логах на доменной границе). // него (детали всё равно в логах на доменной границе).
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil) b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return return
} }
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State) msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
if res.Deduplicated { if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State) msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
} }
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil) b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
} }
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку. const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.` Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
// --- Колбэки (кнопки) --- // --- Колбэки (кнопки) ---
@@ -211,6 +319,12 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
} }
if err != nil { if err != nil {
if errors.Is(err, worker.ErrNotReady) {
// Источник ещё качается — actionable причина, показываем конкретно.
b.answer(cq.ID, "Торрент ещё качается")
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
return
}
b.answer(cq.ID, "Ошибка") b.answer(cq.ID, "Ошибка")
b.send(chatID, opErr("Не удалось выполнить действие", id), nil) b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
return return
+3
View File
@@ -22,6 +22,8 @@ type fakeAPI struct {
sent []sentMsg sent []sentMsg
edits []sentMsg edits []sentMsg
answers []string answers []string
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
fileErr error
} }
type sentMsg struct { type sentMsg struct {
@@ -47,6 +49,7 @@ func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
} }
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil } func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
func (f *fakeAPI) StopReceivingUpdates() {} func (f *fakeAPI) StopReceivingUpdates() {}
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
type fakeIngestor struct { type fakeIngestor struct {
lastReq ingest.Request lastReq ingest.Request
+115
View File
@@ -0,0 +1,115 @@
package tgbot
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func docMsg(userID int64, doc *tgbotapi.Document, caption string) *tgbotapi.Message {
return &tgbotapi.Message{
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID},
Document: doc, Caption: caption,
}
}
// .torrent-документ: скачиваем байты и подаём в приём (подпись — контекстом).
func TestBot_IngestFromDocument(t *testing.T) {
torrentBytes := []byte("d8:announce…bytes")
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(torrentBytes)
}))
defer fileSrv.Close()
b, api, ing, _ := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL + "/file.torrent"
doc := &tgbotapi.Document{FileID: "abc", FileName: "dune.torrent", MimeType: "application/x-bittorrent"}
b.handleMessage(context.Background(), docMsg(7, doc, "Дюна 2"))
if string(ing.lastReq.TorrentData) != string(torrentBytes) {
t.Errorf("TorrentData = %q, want %q", ing.lastReq.TorrentData, torrentBytes)
}
if ing.lastReq.Context != "Дюна 2" {
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
t.Errorf("sent = %+v", api.sent)
}
}
// Документ по расширению .torrent (mime может отсутствовать) тоже принимается.
func TestBot_DocumentByExtension(t *testing.T) {
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("bytes"))
}))
defer fileSrv.Close()
b, api, ing, _ := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL
doc := &tgbotapi.Document{FileID: "x", FileName: "Release.TORRENT"} // без mime, регистр
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(ing.lastReq.TorrentData) == 0 {
t.Error("документ .torrent по расширению не принят")
}
_ = api
}
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
// что stripURL его убрал.
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
api.fileURL = "http://127.0.0.1:1/file/botSECRET123:AAtoken/x"
_, err := b.downloadFile(context.Background(), "fid")
if err == nil {
t.Fatal("ожидалась ошибка транспорта")
}
if strings.Contains(err.Error(), "SECRET123") || strings.Contains(err.Error(), "AAtoken") {
t.Errorf("токен утёк в текст ошибки: %v", err)
}
}
// Не-.torrent документ → подсказка, приёма нет.
func TestBot_NonTorrentDocumentRejected(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
doc := &tgbotapi.Document{FileID: "x", FileName: "photo.jpg", MimeType: "image/jpeg"}
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(ing.lastReq.TorrentData) != 0 || ing.lastReq.Source != "" {
t.Errorf("не-.torrent не должен идти в приём: %+v", ing.lastReq)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не .torrent") {
t.Errorf("нет подсказки об ошибке: %+v", api.sent)
}
}
// Документ приходит ДО ветки pending-подсказки: ожидающая refine-подсказка не
// «съедает» документ (у документа m.Text пуст).
func TestBot_DocumentBeforePendingHint(t *testing.T) {
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("bytes"))
}))
defer fileSrv.Close()
b, api, ing, rev := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL
b.setPending(7, tid) // ждём подсказку для перераспознавания
doc := &tgbotapi.Document{FileID: "x", FileName: "a.torrent", MimeType: "application/x-bittorrent"}
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(rev.refined) != 0 {
t.Errorf("документ ошибочно принят как refine-подсказка: %v", rev.refined)
}
if len(ing.lastReq.TorrentData) == 0 {
t.Error("документ не принят как .torrent")
}
}
+212
View File
@@ -0,0 +1,212 @@
// Package torrent разбирает `.torrent`-файлы (bencode): извлекает инфохэш(и) и
// метаданные раздачи для приёма загрузки байтами.
//
// Инфохэши нормализуются к нижнему hex — в этом же виде их отдаёт qBittorrent
// (поля hash/infohash_v1/infohash_v2), что позволяет сопоставлять задачи с
// раздачами при поллинге. v1-хеш берётся как SHA1 ИСХОДНЫХ байтов info-словаря
// (библиотека хранит их и не переэнкодит), иначе хеш разошёлся бы с qBittorrent.
package torrent
import (
"bytes"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"github.com/anacrolix/torrent/metainfo"
infohashv2 "github.com/anacrolix/torrent/types/infohash-v2"
)
// ErrNotTorrent возвращается, если байты не разбираются как `.torrent`.
var ErrNotTorrent = errors.New("not a torrent file")
// File — один файл раздачи.
type File struct {
Path string // относительный путь ("dir/file.mkv")
Length int64 // размер в байтах
}
// Info — разобранный `.torrent`.
type Info struct {
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
DisplayName string // имя раздачи (info.name)
Files []File // файлы раздачи (для одиночного — один элемент)
TotalLength int64 // суммарный размер
Trackers []string // announce + announce-list (плоско, без дублей)
Comment string // comment, если задан
}
// Parse разбирает байты `.torrent`. Извлекает те инфохэши, которые файл реально
// несёт: v1 (для v1/гибрида) и v2 (для v2/гибрида, BEP52) — каждый в том виде,
// в каком его сообщает qBittorrent. Байты info не переэнкодятся.
func Parse(data []byte) (Info, error) {
mi, err := metainfo.Load(bytes.NewReader(data))
if err != nil {
return Info{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
}
meta, err := mi.UnmarshalInfo()
if err != nil {
return Info{}, fmt.Errorf("%w: info: %v", ErrNotTorrent, err)
}
var hashes []string
if meta.HasV1() {
hashes = append(hashes, strings.ToLower(mi.HashInfoBytes().HexString()))
}
if meta.HasV2() {
v2 := infohashv2.HashBytes(mi.InfoBytes)
hashes = append(hashes, strings.ToLower(v2.HexString()))
}
if len(hashes) == 0 {
return Info{}, fmt.Errorf("%w: no usable infohash", ErrNotTorrent)
}
fs, total := files(&meta)
return Info{
Infohash: hashes[0],
Infohashes: hashes,
DisplayName: meta.BestName(),
Files: fs,
TotalLength: total,
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
Comment: mi.Comment,
}, nil
}
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
// ноль при piece length 0). Дерево файлов — лишь контекст-сигнал, не критично,
// поэтому паники гасим: инфохэш и имя уже извлечены, приём не валим.
func files(meta *metainfo.Info) (out []File, total int64) {
defer func() { _ = recover() }()
for _, fi := range meta.UpvertedFiles() {
seg := fi.Path
if len(seg) == 0 {
seg = fi.PathUtf8
}
p := path.Join(seg...)
if p == "" {
p = meta.BestName()
}
out = append(out, File{Path: p, Length: fi.Length})
total += fi.Length
}
return out, total
}
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
// Context синтезирует человекочитаемый контекст распознавания из полей файла
// (без сети): имя раздачи, суммарный размер, сигнал по дереву файлов, домен
// трекера, комментарий. Строки-факты через "\n"; пустая строка — если полей
// нет. Предназначен для download.Context (recognition, веб-UI), но НЕ для
// вывода отображаемого имени.
func (i Info) Context() string {
var lines []string
if name := strings.TrimSpace(i.DisplayName); name != "" {
lines = append(lines, name)
}
if i.TotalLength > 0 {
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
}
if s := filesSignal(i.Files); s != "" {
lines = append(lines, s)
}
if d := originDomains(i.Trackers); d != "" {
lines = append(lines, "Трекер: "+d)
}
if c := strings.TrimSpace(i.Comment); c != "" {
lines = append(lines, "Комментарий: "+oneLine(c))
}
return strings.Join(lines, "\n")
}
// filesSignal — сигнал по дереву файлов для recognition: число файлов и
// характерные расширения (много видеофайлов → вероятно сериал). Для одиночного
// файла ничего не добавляет (имя раздачи уже есть строкой названия).
func filesSignal(files []File) string {
if len(files) <= 1 {
return ""
}
seen := map[string]bool{}
var exts []string
for _, f := range files {
ext := strings.ToLower(strings.TrimPrefix(path.Ext(f.Path), "."))
if ext == "" || seen[ext] {
continue
}
seen[ext] = true
if len(exts) < 5 {
exts = append(exts, ext)
}
}
s := fmt.Sprintf("Файлов: %d", len(files))
if len(exts) > 0 {
s += " (" + strings.Join(exts, ", ") + ")"
}
return s
}
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
f := float64(n)
i := -1
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
f /= unit
i++
if f < unit {
return fmt.Sprintf("≈ %.1f %s", f, u)
}
}
return fmt.Sprintf("≈ %.1f PiB", f)
}
// originDomains извлекает уникальные домены происхождения из URL трекеров
// (http(s)/udp с хостом), убирая служебные поддомены анонса. Порядок
// сохраняется; результат — через ", ".
func originDomains(trackers []string) string {
var out []string
seen := map[string]bool{}
for _, ref := range trackers {
u, err := url.Parse(strings.TrimSpace(ref))
if err != nil {
continue
}
switch strings.ToLower(u.Scheme) {
case "http", "https", "udp":
default:
continue
}
host := strings.ToLower(u.Hostname())
if host == "" {
continue
}
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
host = s
}
if seen[host] {
continue
}
seen[host] = true
out = append(out, host)
}
return strings.Join(out, ", ")
}
// oneLine схлопывает переводы строк в пробелы (комментарий бывает многострочным).
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
+235
View File
@@ -0,0 +1,235 @@
package torrent
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
// у которых info собран вручную.
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
t.Helper()
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes()
}
// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
func TestParseV2OnlyHash(t *testing.T) {
// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
sum := sha256.Sum256(infoBytes)
wantV2 := hex.EncodeToString(sum[:])
got, err := Parse(writeTorrent(t, infoBytes))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Infohashes) != 1 {
t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
}
if got.Infohash != wantV2 {
t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
}
if len(got.Infohash) != 64 {
t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
}
}
// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
func TestParseHybridHashes(t *testing.T) {
info := metainfo.Info{
Name: "Hybrid", Length: 100, PieceLength: 1024,
Pieces: make([]byte, 20), MetaVersion: 2,
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
sha1sum := sha1.Sum(infoBytes)
sha256sum := sha256.Sum256(infoBytes)
wantV1 := hex.EncodeToString(sha1sum[:])
wantV2 := hex.EncodeToString(sha256sum[:])
got, err := Parse(writeTorrent(t, infoBytes))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got.Infohashes) != 2 {
t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
}
if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
}
}
// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
// проверяет извлечение, не переизобретая хеширование.
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
t.Helper()
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal info: %v", err)
}
sum := sha1.Sum(infoBytes)
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes(), hex.EncodeToString(sum[:])
}
// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
func pieces(n int) []byte { return make([]byte, 20*n) }
func TestParseSingleFile(t *testing.T) {
data, wantV1 := build(t, metainfo.Info{
Name: "Dune.Part.Two.2024.2160p.mkv",
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.Infohash != wantV1 {
t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
}
if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
}
if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
t.Errorf("name = %q", info.DisplayName)
}
if info.TotalLength != 2100 {
t.Errorf("total = %d, want 2100", info.TotalLength)
}
if len(info.Files) != 1 {
t.Errorf("files = %v, want 1", info.Files)
}
ctx := info.Context()
if !strings.Contains(ctx, "Dune.Part.Two") {
t.Errorf("context без имени: %q", ctx)
}
if !strings.Contains(ctx, "Размер:") {
t.Errorf("context без размера: %q", ctx)
}
if !strings.Contains(ctx, "rutracker.org") {
t.Errorf("context без домена трекера: %q", ctx)
}
}
func TestParseMultiFile(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "Fargo.S01",
PieceLength: 1024,
Pieces: pieces(5),
Files: []metainfo.FileInfo{
{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
{Length: 5, Path: []string{"subs", "e01.srt"}},
},
}, "udp://tracker.example.com:80", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(info.Files) != 3 {
t.Fatalf("files = %d, want 3", len(info.Files))
}
if info.TotalLength != 1505 {
t.Errorf("total = %d, want 1505", info.TotalLength)
}
// путь из сегментов через "/"
if info.Files[2].Path != "subs/e01.srt" {
t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
}
ctx := info.Context()
if !strings.Contains(ctx, "Файлов: 3") {
t.Errorf("нет сигнала файлов: %q", ctx)
}
if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
t.Errorf("нет расширений в сигнале: %q", ctx)
}
}
func TestParseTrackersDedupAndComment(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "X",
Length: 10,
PieceLength: 1024,
Pieces: pieces(1),
}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(info.Trackers) == 0 {
t.Fatal("нет трекеров")
}
ctx := info.Context()
// служебный поддомен bt2. срезается
if !strings.Contains(ctx, "t-ru.org") {
t.Errorf("домен не нормализован: %q", ctx)
}
// комментарий схлопнут в одну строку
if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
t.Errorf("комментарий: %q", ctx)
}
}
func TestParseInvalidBytes(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
}{
{"empty", nil},
{"garbage", []byte("this is not a torrent")},
{"bencode-not-dict", []byte("i42e")},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Parse(tc.data); err == nil {
t.Errorf("ожидалась ошибка для %s", tc.name)
}
})
}
}
func TestContextEmptyWhenNoFields(t *testing.T) {
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
// функция — сетевых вызовов в коде нет по построению).
data, _ := build(t, metainfo.Info{
Name: "-", // metainfo.NoName
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
}, "", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
_ = info.Context() // не паникует; состав строк — по полям
}
+152
View File
@@ -0,0 +1,152 @@
package worker
import (
"context"
"errors"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в
// момент (медленного) вывода имени, симулируя параллельную отмену.
type fakeNamer struct {
name string
gotContext string
onCall func()
}
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
f.gotContext = contextText
if f.onCall != nil {
f.onCall()
}
return f.name
}
func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore {
return &fakeStore{downloads: map[string]*store.Download{
id: {
ID: id,
State: store.StateCatched,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash + "&dn=Dune",
Infohashes: hashesOf(id, infohash),
Context: ctxText,
CreatedAt: createdAt,
},
}}
}
const catchedIH = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
// now воркера в тестах — 2026-06-14 10:00 UTC (см. newTestWorker).
var nowStr = store.FormatTime(time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC))
// Успех: выводим имя, добавляем в qBit с rename, переводим catched → downloading
// и сохраняем display_name.
func TestProcessCatchedAddsToQbit(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
}
add := qb.added[0]
if add.Rename != "Дюна: Часть вторая (2024)" {
t.Errorf("rename = %q", add.Rename)
}
if add.Category != "jellybit" || add.URLs[0] != st.downloads["1"].SourceRef {
t.Errorf("add = %+v", add)
}
if nm.gotContext != "Дюна 2" {
t.Errorf("namer получил контекст %q", nm.gotContext)
}
d := st.downloads["1"]
if d.State != store.StateDownloading {
t.Errorf("state = %q, want downloading", d.State)
}
if d.DisplayName != "Дюна: Часть вторая (2024)" {
t.Errorf("display_name = %q", d.DisplayName)
}
}
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{addErr: errors.New("connection refused")}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
}
}
// Предохранитель: catched старше catch_timeout → failed (qbit_add) + уведомление;
// add при этом не вызывается.
func TestProcessCatchedTimeoutFails(t *testing.T) {
old := store.FormatTime(time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) // 1 час до now
st := catchedStore("1", catchedIH, old, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.cfg.CatchTimeout = 10 * time.Minute
n := &recordingNotifier{ch: make(chan notifyEvent, 1)}
w.SetNotifier(n)
w.processCatched(context.Background())
d := st.downloads["1"]
if d.State != store.StateFailed || d.ErrorCode.String != errCodeQbitAdd {
t.Errorf("state = %q code = %q, want failed/qbit_add", d.State, d.ErrorCode.String)
}
if len(qb.added) != 0 {
t.Errorf("add не должен вызываться при таймауте, calls = %d", len(qb.added))
}
if e := waitNotify(t, n); e.ev != EventFailed {
t.Errorf("событие = %q, want failed", e.ev)
}
}
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
// переход в downloading не применяется — состояние остаётся cancelled.
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
// namer имитирует параллельную отмену во время (медленного) вывода имени.
nm := &fakeNamer{name: "X", onCall: func() { st.downloads["1"].State = store.StateCancelled }}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
}
}
// Поллинг активных (downloading) не трогает catched: раздачи в qBittorrent у
// пойманной загрузки ещё нет по дизайну, это не «пропажа».
func TestPollIgnoresCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} // раздач нет
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("catched тронут поллингом: %q", st.downloads["1"].State)
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
State: store.StateDownloading, State: store.StateDownloading,
} }
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes) existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes, nil)
if err != nil { if err != nil {
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err) w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
return return
+7
View File
@@ -6,3 +6,10 @@ import "errors"
// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не // вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не
// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500. // сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500.
var ErrConflict = errors.New("conflict") var ErrConflict = errors.New("conflict")
// ErrNotReady — источник (раздача) присутствует в qBittorrent, но ещё не
// докачан (не в классе classReady), поэтому команда, ведущая к распознаванию
// или раскладке, отклонена: хардлинки на неполные файлы недопустимы. Отдельно
// от ErrConflict (тоже 409), потому что причина actionable — «дождись докачки»
// — и транспорт показывает её конкретным текстом, а не генериком конфликта.
var ErrNotReady = errors.New("source not ready")
+18 -8
View File
@@ -245,23 +245,33 @@ func recoveredState(state string) store.State {
// --- Синхронный preflight перед действием (не доверяем state в БД) --- // --- Синхронный preflight перед действием (не доверяем state в БД) ---
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в // ensureSourceReady синхронно (без дебаунса) проверяет, что раздача есть в
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и // qBittorrent прямо сейчас И докачана (класс classReady). Команды ревью, ведущие
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции. // к распознаванию или раскладке, работают только с готовым источником — иначе
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error { // хардлинки легли бы на неполные файлы (qBittorrent отдаёт имена до завершения).
// - источник исчез → приводим состояние к реальности (orphaned/deleted) и
// ErrConflict;
// - источник есть, но ещё качается → ErrNotReady БЕЗ reconcile: нахождение
// задачи в review/deferred/… легитимно, приводить нечего.
//
// Недоступность qBittorrent — честный отказ операции.
func (w *Worker) ensureSourceReady(ctx context.Context, d *store.Download, op string) error {
if len(d.Infohashes) == 0 { if len(d.Infohashes) == 0 {
return fmt.Errorf("%s: download %s has no infohash", op, d.ID) return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
} }
_, ok, err := w.torrentByInfohash(ctx, d.HashList()) t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", op, err) return fmt.Errorf("%s: %w", op, err)
} }
if ok { if !ok {
return nil
}
// Источник пропал — немедленно приводим состояние к реальности. // Источник пропал — немедленно приводим состояние к реальности.
w.reconcileToReality(ctx, *d, false) w.reconcileToReality(ctx, *d, false)
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict) return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
}
if classify(t.State) != classReady {
return fmt.Errorf("%s: торрент ещё качается: %w", op, ErrNotReady)
}
return nil
} }
// reconcileToReality выводит и проставляет состояние по уже известному факту об // reconcileToReality выводит и проставляет состояние по уже известному факту об
+3 -1
View File
@@ -41,7 +41,9 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
var torrents []qbt.Torrent var torrents []qbt.Torrent
if sourcePresent { if sourcePresent {
torrents = []qbt.Torrent{{Hash: ihTest}} // Раздача докачана (seeding) — classReady: preflight Relink требует
// готовности источника, а не только присутствия.
torrents = []qbt.Torrent{{Hash: ihTest, State: "uploading"}}
} }
w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil) w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil)
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки) w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
+13 -7
View File
@@ -247,6 +247,11 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
w.reconcileToReality(ctx, *d, false) w.reconcileToReality(ctx, *d, false)
return fmt.Errorf("apply: источник удалён из qBittorrent: %w", ErrConflict) return fmt.Errorf("apply: источник удалён из qBittorrent: %w", ErrConflict)
} }
if classify(t.State) != classReady {
// Последний рубеж перед хардлинками: источник ещё качается — не
// линкуем неполные файлы (задача могла войти в review иным путём).
return fmt.Errorf("apply: торрент ещё качается: %w", ErrNotReady)
}
w.transition(ctx, *d, store.StateLinking, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil { if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
@@ -321,7 +326,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл // (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная // перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review). // перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте. // Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
func (w *Worker) Relink(ctx context.Context, id string) error { func (w *Worker) Relink(ctx context.Context, id string) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -333,9 +338,10 @@ func (w *Worker) Relink(ctx context.Context, id string) error {
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing { if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict) return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
} }
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при // Источник нужен для распознавания и должен быть докачан — проверяем
// его отсутствии приводим состояние к реальности (orphaned/deleted). // синхронно (без дебаунса); отсутствие приводит состояние к реальности
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil { // (orphaned/deleted), недокачанный — ErrNotReady.
if err := w.ensureSourceReady(ctx, d, "relink"); err != nil {
return err return err
} }
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки. // Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
@@ -366,7 +372,7 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
if err != nil { if err != nil {
return err return err
} }
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil { if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil {
return err return err
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
@@ -388,7 +394,7 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
if err != nil { if err != nil {
return err return err
} }
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil { if err := w.ensureSourceReady(ctx, d, "refine"); err != nil {
return err return err
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
@@ -413,7 +419,7 @@ func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error
if err != nil { if err != nil {
return err return err
} }
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil { if err := w.ensureSourceReady(ctx, d, "set type"); err != nil {
return err return err
} }
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
+114 -7
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
@@ -106,7 +108,7 @@ func revertedDownload(id string) *store.Download {
func TestRelink_RevertedToRecognizing(t *testing.T) { func TestRelink_RevertedToRecognizing(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(revertedDownload("1")) st.put(revertedDownload("1"))
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), "1"); err != nil { if err := w.Relink(context.Background(), "1"); err != nil {
@@ -125,7 +127,7 @@ func TestRelink_CancelledToRecognizing(t *testing.T) {
d := revertedDownload("1") d := revertedDownload("1")
d.State = store.StateCancelled d.State = store.StateCancelled
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), "1"); err != nil { if err := w.Relink(context.Background(), "1"); err != nil {
@@ -155,7 +157,7 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) {
d := completedDownload("1") d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), "1"); err != nil { if err := w.Rerecognize(context.Background(), "1"); err != nil {
@@ -218,6 +220,87 @@ func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
} }
} }
// TestReviewCommands_RejectNotReadySource — readiness-preflight: команды,
// вводящие задачу в recognizing, отклоняют недокачанный (downloading-класс)
// источник с ErrNotReady и НЕ трогают состояние. Иначе недокачанную задачу
// можно было бы провести в авто-раскладку и захардлинкать неполные файлы.
func TestReviewCommands_RejectNotReadySource(t *testing.T) {
notReady := []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "downloading"}}
// review/deferred-команды на задаче в deferred/review.
cases := []struct {
name string
state store.State
call func(w *Worker) error
}{
{"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }},
{"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }},
{"set type", store.StateReview, func(w *Worker) error { return w.SetType(context.Background(), "1", "series") }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = tc.state
st.put(d)
w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)
if err := tc.call(w); !errors.Is(err, ErrNotReady) {
t.Fatalf("err = %v, want ErrNotReady", err)
}
if got := st.downloads["1"].State; got != tc.state {
t.Errorf("state = %q, want %q (не тронуто)", got, tc.state)
}
})
}
// Relink из reverted — тоже требует готовности источника.
t.Run("relink", func(t *testing.T) {
st := newMemStore()
st.put(revertedDownload("1"))
w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
t.Fatalf("err = %v, want ErrNotReady", err)
}
if got := st.downloads["1"].State; got != store.StateReverted {
t.Errorf("state = %q, want reverted (не тронуто)", got)
}
if _, ok := st.overrides["1"][ovrForceReview]; ok {
t.Errorf("force_review проставлен, а не должен: отказ до записи override")
}
})
}
// TestApply_RejectsNotReadySource — последний рубеж: Apply не создаёт хардлинки
// на недокачанный источник, даже если задача оказалась в review.
func TestApply_RejectsNotReadySource(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
planJSON, _ := json.Marshal(seriesResult().Plan)
st.recs = append(st.recs, &store.Recognition{
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
})
lay, err := layout.New(layout.Config{MoviesDir: t.TempDir(), SeriesDir: t.TempDir()}, nil)
if err != nil {
t.Fatal(err)
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: "/d", State: "downloading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
if err := w.Apply(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
t.Fatalf("err = %v, want ErrNotReady", err)
}
if got := st.downloads["1"].State; got != store.StateReview {
t.Errorf("state = %q, want review (не тронуто)", got)
}
if len(st.links) != 0 {
t.Errorf("file_links = %d, want 0 (хардлинки не создаём)", len(st.links))
}
}
// memStore — полноценный in-memory store для тестов Ф3. // memStore — полноценный in-memory store для тестов Ф3.
type memStore struct { type memStore struct {
downloads map[string]*store.Download downloads map[string]*store.Download
@@ -226,6 +309,7 @@ type memStore struct {
overrides map[string]map[string]string overrides map[string]map[string]string
links []store.FileLink links []store.FileLink
candidates []store.MetadataCandidate candidates []store.MetadataCandidate
torrents map[string][]byte
} }
func newMemStore() *memStore { func newMemStore() *memStore {
@@ -285,7 +369,7 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*s
return nil, nil return nil, nil
} }
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) { func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil { if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil return existing, nil
} }
@@ -297,11 +381,24 @@ func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downlo
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
} }
m.downloads[id] = &cp m.downloads[id] = &cp
if len(torrentBlob) > 0 {
if m.torrents == nil {
m.torrents = map[string][]byte{}
}
m.torrents[id] = torrentBlob
}
d.ID = id d.ID = id
d.Infohashes = cp.Infohashes d.Infohashes = cp.Infohashes
return nil, nil return nil, nil
} }
func (m *memStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
if data, ok := m.torrents[downloadID]; ok {
return data, nil
}
return nil, store.ErrNotFound
}
func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error { func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := m.downloads[id] d, ok := m.downloads[id]
if !ok { if !ok {
@@ -346,6 +443,16 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
return nil return nil
} }
func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) error {
d, ok := m.downloads[id]
if !ok || d.State != store.StateCatched {
return fmt.Errorf("promote catched %s: not in catched", id)
}
d.State = store.StateDownloading
d.DisplayName = displayName
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error { func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok { if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n d.SourceMissCount = n
@@ -666,7 +773,7 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
d := completedDownload("1") d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil { if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
@@ -688,7 +795,7 @@ func TestSetType(t *testing.T) {
d := completedDownload("1") d := completedDownload("1")
d.State = store.StateReview d.State = store.StateReview
st.put(d) st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.SetType(context.Background(), "1", "series"); err != nil { if err := w.SetType(context.Background(), "1", "series"); err != nil {
@@ -782,7 +889,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
st.recs = append(st.recs, &store.Recognition{ st.recs = append(st.recs, &store.Recognition{
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)), ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
}) })
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}} qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay) w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series} return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
+120
View File
@@ -0,0 +1,120 @@
package worker
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"testing"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// buildTorrent — валидные байты .torrent и их v1-инфохэш.
func buildTorrent(t *testing.T, name string) (data []byte, infohash string) {
t.Helper()
info := metainfo.Info{Name: name, Length: 2048, PieceLength: 1024, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal info: %v", err)
}
sum := sha1.Sum(infoBytes)
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write metainfo: %v", err)
}
return buf.Bytes(), hex.EncodeToString(sum[:])
}
// catchedTorrentStore — пойманная torrent-загрузка с сохранёнными байтами.
func catchedTorrentStore(id string, data []byte, infohash string) *fakeStore {
return &fakeStore{
downloads: map[string]*store.Download{
id: {
ID: id,
State: store.StateCatched,
SourceType: store.SourceTorrent,
SourceRef: "Some.Release.Name", // человекочитаемый референс, не URL
Infohashes: hashesOf(id, infohash),
CreatedAt: nowStr,
},
},
torrents: map[string][]byte{id: data},
}
}
// Пойманная torrent-загрузка добавляется в qBittorrent ФАЙЛОМ (Torrents), не URL.
func TestProcessCatchedTorrentAddsAsFile(t *testing.T) {
data, ih := buildTorrent(t, "Dune.mkv")
st := catchedTorrentStore("1", data, ih)
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "Дюна (2024)"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
}
add := qb.added[0]
if len(add.URLs) != 0 {
t.Errorf("torrent не должен добавляться по URLs: %v", add.URLs)
}
if len(add.Torrents) != 1 || !bytes.Equal(add.Torrents[0], data) {
t.Errorf("байты .torrent не переданы в Add.Torrents")
}
if add.Rename != "Дюна (2024)" {
t.Errorf("rename = %q", add.Rename)
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
}
// Retry torrent-загрузки без живой раздачи добавляет её ФАЙЛОМ (регрессия
// BLOCKER 1: раньше add гейтился на magnet и torrent активировался без раздачи).
func TestRetryTorrentAddsAsFile(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{} // раздачи в qBittorrent нет → должен добавить заново
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.added) != 1 {
t.Fatalf("ожидалось повторное добавление, got %d", len(qb.added))
}
if len(qb.added[0].Torrents) != 1 || len(qb.added[0].URLs) != 0 {
t.Errorf("retry torrent должен добавляться файлом, add = %+v", qb.added[0])
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
}
// Retry torrent-загрузки без сохранённых байтов — откат активации (не оставляем
// «качающуюся» задачу без раздачи).
func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
_, ih := buildTorrent(t, "X.mkv")
st := catchedTorrentStore("1", nil, ih) // байтов нет
delete(st.torrents, "1")
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка (нет байтов torrent)")
}
if len(qb.added) != 0 {
t.Errorf("add не должен вызываться без байтов")
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
}
}
+150 -7
View File
@@ -24,9 +24,11 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/torrent"
) )
// Стадии (capability) — адресуют запись к подсистеме при корреляции по // Стадии (capability) — адресуют запись к подсистеме при корреляции по
@@ -44,12 +46,18 @@ type Store interface {
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error) ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
GetDownload(ctx context.Context, id string) (*store.Download, error) GetDownload(ctx context.Context, id string) (*store.Download, error)
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
// PromoteCatched атомарно переводит catched → downloading с записью имени
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
PromoteCatched(ctx context.Context, id, displayName string) error
SetSourceMissCount(ctx context.Context, id string, n int) error SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// Идентичность/инвариант «одна активная загрузка на infohash». // Идентичность/инвариант «одна активная загрузка на infohash».
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
// GetTorrentData — сохранённые байты `.torrent` (для добавления раздачи
// файлом на шаге processCatched/Retry у source_type=torrent).
GetTorrentData(ctx context.Context, downloadID string) ([]byte, error)
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
@@ -85,6 +93,12 @@ type Recognizer interface {
Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error) Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error)
} }
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
type Namer interface {
DeriveName(ctx context.Context, contextText, hint string) string
}
// Layouter — раскладчик хардлинками (layout.Layouter). // Layouter — раскладчик хардлинками (layout.Layouter).
type Layouter interface { type Layouter interface {
BuildLinks(p layout.Plan) ([]layout.Link, error) BuildLinks(p layout.Plan) ([]layout.Link, error)
@@ -111,6 +125,10 @@ const (
errCodeMagnetTimeout = "magnet_timeout" errCodeMagnetTimeout = "magnet_timeout"
errCodeStalled = "stalled" errCodeStalled = "stalled"
errCodeQbitError = "qbit_error" errCodeQbitError = "qbit_error"
// errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
// восстановлению сверкой не подлежит.
errCodeQbitAdd = "qbit_add"
) )
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
@@ -134,6 +152,7 @@ type Config struct {
PollInterval time.Duration PollInterval time.Duration
StuckAfter time.Duration // stalledDL дольше → stuck StuckAfter time.Duration // stalledDL дольше → stuck
MagnetTimeout time.Duration // metaDL дольше → failed MagnetTimeout time.Duration // metaDL дольше → failed
CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки). // SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже). // <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
SourceMissingThreshold int SourceMissingThreshold int
@@ -181,6 +200,7 @@ type Worker struct {
qbt QBittorrent qbt QBittorrent
recognizer Recognizer recognizer Recognizer
layouter Layouter layouter Layouter
namer Namer // опц. вывод отображаемого имени на шаге добавления catched
cfg Config cfg Config
log *slog.Logger log *slog.Logger
@@ -209,6 +229,10 @@ type Worker struct {
// одной задачи (см. failNotified). // одной задачи (см. failNotified).
const failNotifyDebounce = time.Hour const failNotifyDebounce = time.Hour
// SetNamer подключает вывод отображаемого имени для шага добавления catched
// (до запуска Run). nil → имя не выводим, добавляем без rename.
func (w *Worker) SetNamer(n Namer) { w.namer = n }
// SetNotifier подключает исходящие пинги (до запуска Run). // SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
@@ -291,12 +315,122 @@ func (w *Worker) pollOnce(ctx context.Context) {
if err := w.Poll(ctx); err != nil { if err := w.Poll(ctx); err != nil {
w.log.Warn("poll failed", "error", err) w.log.Warn("poll failed", "error", err)
} }
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
w.processCatched(ctx)
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке). // Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
if w.recognizer != nil { if w.recognizer != nil {
w.recognizePending(ctx) w.recognizePending(ctx)
} }
} }
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
// qbt.Add) идут ВНЕ w.mu, чтобы не задерживать команды транспортов и поллинг;
// под w.mu берутся только короткие DB-переходы (с ре-валидацией state=catched).
func (w *Worker) processCatched(ctx context.Context) {
w.mu.Lock()
catched, err := w.store.ListDownloadsByState(ctx, store.StateCatched)
w.mu.Unlock()
if err != nil {
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
return
}
for _, d := range catched {
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
// Предохранитель: устойчивая невозможность добавить в qBittorrent.
if w.cfg.CatchTimeout > 0 {
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
w.mu.Lock()
// Ре-валидация под замком: список catched снят раньше, задачу
// могли отменить (catched → cancelled) в это окно — тогда failed
// не навязываем (иначе затёрли бы cancelled и слали лишний пинг).
if cur, err := w.store.GetDownload(cctx, d.ID); err == nil && cur.State == store.StateCatched {
w.transition(cctx, d, store.StateFailed, errCodeQbitAdd,
fmt.Sprintf("not added to qBittorrent after %s", age.Truncate(time.Second)))
}
w.mu.Unlock()
continue
}
}
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
if prepErr != nil {
// Байты torrent недоступны (не должно быть при штатном приёме) —
// остаёмся в catched, повтор на следующем тике.
logctx.From(cctx).Warn("catched prepare add failed, will retry", "error", prepErr)
continue
}
var rename string
if w.namer != nil {
rename = w.namer.DeriveName(cctx, d.Context, hint)
}
addReq.Rename = rename
addErr := w.qbt.Add(cctx, addReq)
if addErr != nil {
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
continue
}
// Успех: короткий переход под w.mu с ре-валидацией state=catched
// (загрузку могли отменить, пока шли сетевые вызовы).
w.mu.Lock()
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
} else {
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
"to", store.StateDownloading)
}
w.mu.Unlock()
}
}
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
created, err := d.CreatedTime()
if err != nil {
w.log.Warn("cannot determine catched age",
"capability", capIngest, "download_id", d.ID,
"created_at", d.CreatedAt, "error", err)
return 0, false
}
return w.now().Sub(created), true
}
// sourceAddParts собирает параметры добавления раздачи в qBittorrent по типу
// источника и подсказку имени (hint) для namer. magnet/url — ссылкой (URLs),
// hint из полей ссылки; torrent — байтами файла (Torrents), hint из имени
// раздачи. Общий для обоих add-путей воркера (processCatched и Retry), чтобы
// диспетч по source_type был в одном месте. Rename вызывающий проставляет сам
// (после namer). Ошибку возвращает лишь torrent-ветка (байты недоступны).
func (w *Worker) sourceAddParts(ctx context.Context, d store.Download) (hint string, req qbt.AddRequest, err error) {
req = qbt.AddRequest{Category: w.cfg.Category, SavePath: w.cfg.SavePath}
if d.SourceType == store.SourceTorrent {
data, derr := w.store.GetTorrentData(ctx, d.ID)
if derr != nil {
return "", qbt.AddRequest{}, fmt.Errorf("torrent bytes: %w", derr)
}
if info, perr := torrent.Parse(data); perr == nil {
hint = info.DisplayName
}
req.Torrents = [][]byte{data}
return hint, req, nil
}
// magnet/url: SourceRef — добавляемая ссылка, и источник hint для namer.
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
hint = info.DisplayName
}
req.URLs = []string{d.SourceRef}
return hint, req, nil
}
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их. // Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и // Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
// усыновлённые по тегу раздачи, а discovery — увидел новые. // усыновлённые по тегу раздачи, а discovery — увидел новые.
@@ -571,12 +705,21 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
} }
return fmt.Errorf("retry: %w", err) return fmt.Errorf("retry: %w", err)
} }
if !alive && d.SourceType == store.SourceMagnet { if !alive {
if err := w.qbt.Add(ctx, qbt.AddRequest{ // Добавляем заново по типу источника (magnet — ссылкой, torrent —
URLs: []string{d.SourceRef}, // сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
Category: w.cfg.Category, // не зовём — имя уже могло быть выведено при первом добавлении).
SavePath: w.cfg.SavePath, _, addReq, prepErr := w.sourceAddParts(ctx, *d)
}); err != nil { if prepErr != nil {
// Байты torrent недоступны — откатываем активацию, задача не должна
// «качаться» без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed",
"capability", capReview, "download_id", id, "error", rbErr)
}
return fmt.Errorf("retry: prepare add: %w", prepErr)
}
if err := w.qbt.Add(ctx, addReq); err != nil {
// Активация уже прошла — откатываем задачу в прежнее состояние, // Активация уже прошла — откатываем задачу в прежнее состояние,
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent. // чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil { if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
+29 -1
View File
@@ -23,6 +23,7 @@ const (
type fakeStore struct { type fakeStore struct {
downloads map[string]*store.Download downloads map[string]*store.Download
transitions []transition transitions []transition
torrents map[string][]byte // download_id → байты .torrent
} }
type transition struct { type transition struct {
@@ -99,7 +100,7 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*
return nil, nil return nil, nil
} }
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) { func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil { if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil return existing, nil
} }
@@ -111,11 +112,24 @@ func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downl
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
} }
f.downloads[id] = &cp f.downloads[id] = &cp
if len(torrentBlob) > 0 {
if f.torrents == nil {
f.torrents = map[string][]byte{}
}
f.torrents[id] = torrentBlob
}
d.ID = id d.ID = id
d.Infohashes = cp.Infohashes d.Infohashes = cp.Infohashes
return nil, nil return nil, nil
} }
func (f *fakeStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
if data, ok := f.torrents[downloadID]; ok {
return data, nil
}
return nil, store.ErrNotFound
}
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error { func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
@@ -163,6 +177,20 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.Stat
return nil return nil
} }
func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
if d.State != store.StateCatched {
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
}
d.State = store.StateDownloading
d.DisplayName = displayName
f.transitions = append(f.transitions, transition{id, store.StateDownloading})
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error { func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-07
@@ -0,0 +1,150 @@
## Context
Текущий `Ingest` (`internal/ingest/ingest.go`) синхронно: парсит magnet,
дедуплицирует, выводит имя через `namer.DeriveName` (потенциально **медленный
LLM** — `extractViaLLM`), создаёт `download` сразу в `downloading` и вызывает
`qbt.Add`, и лишь затем возвращает `Result`. Медленный LLM и внешний вызов к
qBit задерживают ответ транспорту (HTTP/веб-UI/Telegram) и расширяют окно
«строка в БД есть, в qBittorrent ещё нет».
Worker (`internal/worker`) уже ведёт поллинг-цикл под единой блокировкой
переходов: сверяет раздачи (`discover` усыновляет по категории/тегу с дедупом
по infohash), ходит по `ListDownloadsByState(StateDownloading)` и реагирует на
«active download not found in qbittorrent». Реконсилятор оперирует своими
`desyncStates` (`orphaned`, …), в которые `catched` не входит.
Состояния — `internal/store/download.go` (`State`, `terminalStates`,
`statesInGroup`). Активность выводится из `state` (нетерминальное = активное).
## Goals / Non-Goals
**Goals:**
- Быстрый ответ приёма: синхронно только парс + дедуп + запись `catched`.
- Вынести медленный вывод имени и `qbt.Add` в асинхронный шаг worker'а.
- Сохранить инвариант «≤1 активная загрузка на infohash» (в т.ч. с `catched`).
- Корректно показать `catched` в веб-UI; не считать его пропажей раздачи.
**Non-Goals:**
- Отдельное состояние `added-to-qbittorrent` — схлопнуто в `catched →
downloading` (решение развилки).
- Немедленный пинок фоновой добавки — двигаем worker-циклом (решение развилки);
задержка ≤ `poll_interval` приемлема, ведь клиенту уже ответили.
- Изменение схемы БД, API транспортов, распознавания/раскладки.
- Перенос синтеза контекста из полей magnet — он дёшев и остаётся в приёме.
## Decisions
### Р1. Новое состояние `catched`, нетерминальное активное
`StateCatched = "catched"`. Не входит в `terminalStates` → автоматически
считается активным для `CreateDownloadIfNoActive` и инварианта. Добавляется в
`statesInGroup(GroupActive)` рядом с `downloading` — чтобы попадать в активную
группу списка и в поиск.
### Р2. Приём пишет `catched`, без namer и без qBit
`Ingest`: парс → синтез контекста (как сейчас) → `CreateDownloadIfNoActive` с
`State: StateCatched`, `DisplayName: ""` (имя выведет worker). Ни
`namer.DeriveName`, ни `qbt.Add` в приёме не вызываются. `Result` возвращается
сразу после записи. Транспорты (`httpapi`, `tgbot`) не меняются — они уже
работают через `Result`.
Зависимость `Namer` из `ingest.Service` **переезжает** в worker (или worker
получает её отдельно). `ingest` перестаёт зависеть от `naming`/`qbt` в пути
приёма (qbt-зависимость в ingest может уйти совсем, если не нужна для дедупа).
### Р3. Асинхронный шаг worker'а: `catched → downloading`, сеть — вне замка
**Критично:** `w.mu` в worker'е сериализует ВЕСЬ поллинг-цикл И команды
транспортов (`Cancel`, `Retry`, review). Медленный `namer.DeriveName` (до
`max_retries` сетевых попыток) и `qbt.Add` под этим замком заморозили бы все
действия пользователя на секунды каждый тик — это ровно та блокировка, которую
change устраняет. Поэтому:
1. Под `w.mu` (быстро): снять список `ListDownloadsByState(StateCatched)`.
2. **Вне `w.mu`** (для каждой загрузки): вывести имя `namer.DeriveName(ctx,
d.Context, dnHint)`, вызвать `qbt.Add(urls=d.SourceRef, category, savepath,
rename=name)`. Имя выводится непосредственно перед `add` (`rename` действует
только при добавлении).
3. Снова под `w.mu` (быстро): **ре-валидировать** `state == catched` (мог быть
отменён/добавлен параллельно) и записать переход `catched → downloading` +
`display_name`. Ре-валидацию обеспечивает гард `setState` (target
нетерминальный → `state NOT IN terminalStates`): если пользователь успел
`catched → cancelled`, переход корректно отклонится.
То есть под сериализацией переходов — только запись перехода в БД, а не сетевые
вызовы. Спека («Добавление пойманной загрузки») формулирует это так же: под
блокировкой сериализуется переход, не `add`/namer.
`dnHint` (dn из magnet) worker получает разбором `d.SourceRef`
(`magnet.Parse`) — дёшево, без сети; для `add` используется сам `d.SourceRef`
(URL), хеши уже есть в `d.Infohashes`.
_Альтернатива:_ хранить hint отдельным полем. Отвергнуто — `SourceRef` уже есть,
повторный парс тривиален, схему не трогаем.
### Р4. Сбой `add` — ретрай тиком, предохранитель `catch_timeout`
Парс magnet уже прошёл синхронно в приёме, поэтому в `catched` ссылка
валидна — сбои `add` почти всегда транзиентны (qBit недоступен). Поэтому
неуспешный `add` **оставляет** загрузку в `catched` (повтор на следующем тике),
а не уводит в `failed` по первому сбою. Страховка от устойчивой недоступности —
предохранитель `catch_timeout` (новый конфиг, дефолт консервативный, напр. по
образцу `magnet_timeout`): `catched` старше него → `failed` (`qbit_add`) +
уведомление автора. Это переиспользует существующий паттерн
таймаутов-предохранителей (`magnet_timeout`/`stuck_after`).
### Р5. `catched` исключён из проверок пропажи раздачи
Поллинг активных (`worker.go`: `ListDownloadsByState(StateDownloading)`) уже не
включает `catched` — но фиксируем это требованием и тестом. Реконсилятор
(`desyncStates`) `catched` не содержит. `discover`: когда worker добавит
раздачу catched-загрузки, следующий тик увидит её по категории, но exists-чек по
infohash найдёт активную загрузку и не заведёт дубль (инвариант держится).
### Р6. Веб-UI: бейдж/фаза `catched`
Добавить подпись бейджа и фазу жизненного цикла для `catched` (перед
`downloading`), включить в активную группу. Заголовок при пустом `display_name`
уже деградирует по фолбеку. Секции раздачи/живого прогресса для `catched` нет
(нет qBit-записи) — шаблон должен это переносить без ошибок (обычно уже так, т.к.
телеметрия ищется по infohash и не находится).
## Risks / Trade-offs
- [Задержка появления в qBit до ~`poll_interval` (5с)] → Приемлемо: клиенту уже
ответили; пользователь видит `catched` в UI. При желании позже — немедленный
пинок, но вне объёма.
- [Гонка discover ↔ шаг добавления (worker добавил, тот же/следующий тик
усыновляет)] → Дедуп по infohash в `discover` (exists-чек) уже защищает;
оба пути под общей блокировкой переходов.
- [Пустой `display_name` в `catched` виден в UI] → Фолбек заголовка уже есть
(распознанное/усечённый источник); визуально корректно.
- [Namer/LLM-ошибка на шаге добавления] → Как и раньше best-effort: пустое имя →
`add` без `rename`; шаг добавления не срывается из-за namer.
- [Учёт `catched` во всех местах, где перечислены активные состояния] →
Единая точка `statesInGroup` + аудит по `StateDownloading`-упоминаниям в
worker/store; покрыть тестами дедупа и группировки.
## Migration Plan
Аддитивно: новое состояние и новый конфиг `catch_timeout` (с дефолтом — старый
конфиг валиден). Существующие загрузки в `downloading`/терминальных не
затронуты. Новый путь приёма применяется к новым загрузкам.
Откат — ревертом кода. Загрузки, застрявшие в `catched` на момент отката,
старая логика не знает и `retry` их не поднимет (`Retry` разрешён только из
`failed`/`stuck`), а как активные они ещё и блокируют повторный приём того же
infohash. Окно мало (`catched` живёт секунды до тика worker'а), но при откате
такие строки нужно снять вручную: `UPDATE download SET state='failed',
error_code='qbit_add' WHERE state='catched'` — после чего они доступны штатному
`retry`. Зафиксировать в задаче/рантбуке отката.
## Open Questions
- Значение дефолта `catch_timeout` (5–15 мин?) — уточнить при apply, на спеку
не влияет.
- Нужен ли отдельный `error_code` для `catch_timeout` или переиспользуем
`qbit_add` (взято `qbit_add`, т.к. корень — невозможность добавить).
@@ -0,0 +1,78 @@
## Why
Сейчас приём (`Ingest`) синхронно делает всё: парсит источник, выводит
отображаемое имя (потенциально **медленный вызов LLM** в `namer.DeriveName`) и
добавляет источник в qBittorrent — и только потом отвечает клиенту. Долгий LLM
и внешний запрос к qBit задерживают ответ HTTP API / веб-UI / Telegram и
расширяют окно «строка в БД есть, в qBittorrent ещё нет».
Идея: сделать приём **быстрым** — синхронно только валидировать и сохранить
загрузку (новое состояние `catched`), сразу вернув ответ; вывод имени и
добавление в qBittorrent вынести в отдельный **асинхронный шаг машины
состояний**, который двигает worker.
## What Changes
- Новое состояние **`catched`** — загрузка поймана и персистентно сохранена
(быстрый путь). Нетерминальное, активное (участвует в инварианте «≤1 активная
загрузка на infohash»).
- **Приём (`ingest`) — быстрый**: парс magnet, извлечение инфохэшей, синтез
контекста из полей ссылки (дёшево, без сети), атомарный дедуп и запись
загрузки в `catched`. Ответ клиенту сразу. Синхронного вывода имени и
добавления в qBittorrent в приёме больше нет.
- **Асинхронный шаг (`download-tracking`, worker)**: на каждом тике worker
подхватывает `catched`-загрузки, выводит отображаемое имя из контекста (LLM +
фолбек), добавляет источник в qBittorrent (категория/savepath/rename) и
переводит `catched → downloading` (отдельного `added-to-qbittorrent` нет —
`downloading` и так значит «в qBit, возможно metaDL»).
- **Обработка сбоев вне запроса клиента**: неуспешный `add` оставляет загрузку
в `catched` (worker перетыкивает на следующем тике); предохранитель
`catch_timeout` уводит долго-зависший `catched` в `failed`
(`error_code` `qbit_add`) с уведомлением автора.
- `catched` **исключён** из проверок «раздача не найдена» (у него раздачи нет
по дизайну) — ни поллинг, ни сверка не считают его рассинхроном/orphaned.
- **Веб-UI** показывает промежуточное состояние `catched` (бейдж/фаза
жизненного цикла), заголовок деградирует, пока имя не выведено (фолбек уже
есть). `catched` попадает в активную группу списка.
## Capabilities
### New Capabilities
_Нет._ Изменение переиспользует существующие capabilities.
### Modified Capabilities
- `ingest`: приём становится быстрым — сохранение в `catched` и мгновенный
ответ; синхронный вывод имени и добавление в qBittorrent из приёма убраны
(переезжают в асинхронный шаг). Требования по выводу имени переформулированы:
выполняются на шаге добавления, а не в пути ответа клиента.
- `download-tracking`: добавляется шаг «добавление пойманной загрузки в
qBittorrent» (вывод имени + `add` + переход `catched → downloading`),
предохранитель `catch_timeout`, исключение `catched` из проверок пропажи
раздачи.
- `web-ui`: человекочитаемый бейдж для `catched`; активная группа включает
`catched`; заголовок при пустом имени — по фолбеку; карточка `catched`
самообновляется htmx-поллингом до перехода в `downloading`.
_Без спек-правок:_ `state-reconciliation` и `live-status` не меняются.
Исключение `catched` из проверок рассинхрона нормативно закреплено требованием
`download-tracking` «catched не считается пропажей раздачи» (само поведение уже
верно — `catched` не входит в `desyncStates` и дебаунс пропажи источника его не
трогает). Иллюстративное перечисление активных состояний в тексте
`state-reconciliation` остаётся на кросс-ссылке и будет выверено при следующем
касании этой спеки. `live-status`: у `catched` нет qBit-телеметрии по дизайну —
живой прогресс для него корректно отсутствует.
## Impact
- Код: `internal/store` (состояние `StateCatched`, группа active), `internal/
ingest` (быстрый путь: убрать namer/qbt-add, писать `catched`), `internal/
worker` (новый шаг обработки `catched`: namer + qbt-add + переход, таймаут),
`internal/httpapi`+`internal/tgbot` (без правок API — ответ и так по Result),
веб-UI шаблоны (бейдж/фаза `catched`).
- Конфиг: новый `catch_timeout` (предохранитель), дефолт консервативный.
- Данные: у активной загрузки теперь есть фаза без infohash-раздачи; схема БД
не меняется (используем существующие `state`/`error_code`/`error_msg`).
- Совместимость: существующие загрузки не затронуты; переход одноразовый на
уровне логики приёма.
@@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
#### Scenario: Пойманная загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched`
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
- **WHEN** worker пытается добавить источник и `add` не удался
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
### Requirement: Предохранитель зависшего catched
Система SHALL переводить загрузку, задержавшуюся в `catched` дольше
`catch_timeout` (конфигурируемый предохранитель, дефолт консервативный), в
`failed` (`error_code` `qbit_add`) и уведомлять автора. Возраст SHALL считать
от времени попадания в `catched` (создания загрузки). Предохранитель —
редкий страховочный механизм на случай устойчивой недоступности qBittorrent, а
не штатный путь.
#### Scenario: catched висит дольше таймаута
- **GIVEN** загрузка в `catched` дольше `catch_timeout`
- **WHEN** идёт тик поллинга
- **THEN** загрузка переходит в `failed` с `error_code` `qbit_add`
- **AND** автор загрузки уведомляется
### Requirement: catched не считается пропажей раздачи
Система SHALL исключать состояние `catched` из проверок «раздача не найдена в
qBittorrent» — как в поллинге активных загрузок, так и в сверке рассинхрона
(`state-reconciliation`). У пойманной загрузки раздачи в qBittorrent ещё нет по
дизайну, поэтому её отсутствие система SHALL NOT трактовать как рассинхрон,
`orphaned` или пропажу источника.
#### Scenario: Отсутствие раздачи у catched — не рассинхрон
- **GIVEN** загрузка в `catched` (раздачи в qBittorrent ещё нет)
- **WHEN** идёт тик поллинга и сверки
- **THEN** загрузка не считается пропавшей/рассинхронизированной и остаётся в
`catched` (до добавления воркером или срабатывания `catch_timeout`)
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`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: Отображаемое имя торрента из контекста
На шаге добавления пойманной загрузки в qBittorrent (worker) система SHALL
выводить из контекста загрузки человекочитаемое отображаемое имя и передавать
его в qBittorrent (параметр `rename` API `/torrents/add`), чтобы задача в списке
qBit не показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL
**сохранять у загрузки** (`download.display_name`) для последующего показа
заголовком в веб-UI.
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
год; для сериала — номер сезона, если он определён), а не куском сырого
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
SHALL обрезаться по ограничению длины.
Вывод имени SHALL выполняться на шаге добавления, непосредственно перед вызовом
`add` (параметр `rename` действует только в момент добавления), а НЕ в
синхронном пути ответа приёма. В состоянии `catched` (до добавления)
`download.display_name` ещё пуст — веб-UI берёт заголовок из фолбека.
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
или раскладку — реальные пути система по-прежнему читает из qBit API.
#### Scenario: Имя из контекста передаётся в qBittorrent
- **WHEN** на шаге добавления получен непустой контекст, из которого удалось
вывести имя
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
где режиссёр и год опциональны
#### Scenario: Имя сохраняется у загрузки
- **WHEN** на шаге добавления выведено непустое отображаемое имя
- **THEN** система сохраняет его в `download.display_name` (обновлением записи
загрузки)
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
#### Scenario: Контекст пуст или имя не получено
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
- **THEN** система добавляет загрузку без параметра `rename`
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
фолбека (распознанное название или усечённый источник)
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Карточка/страница загрузки в `catched` SHALL самообновляться самозавершающимся
htmx-поллингом (см. конвенцию веб-UI): по переходе загрузки в `downloading`
интерфейс SHALL отражать это без перезагрузки страницы (подхватить бейдж,
выведенное имя и появившийся живой прогресс), а поллинг фазы `catched` SHALL
завершаться, как только загрузка её покинула.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается
@@ -0,0 +1,77 @@
## 1. Состояние catched
- [x] 1.1 Добавить `StateCatched State = "catched"` в `internal/store/download.go`;
убедиться, что оно НЕ в `terminalStates` (нетерминальное = активное)
- [x] 1.2 Включить `StateCatched` в `statesInGroup(GroupActive)`; проверить
поиск/фильтры списка
- [x] 1.3 Тест: `catched` активно для дедупа (`CreateDownloadIfNoActive` держит
инвариант ≤1 активная на infohash с участием `catched`)
## 2. Быстрый приём (ingest)
- [x] 2.1 `Ingest`: писать `download` в `StateCatched` с пустым `DisplayName`;
убрать из синхронного пути `namer.DeriveName` и `qbt.Add`; вернуть `Result`
сразу после записи
- [x] 2.2 Убрать из `ingest.Service` ставшие ненужными зависимости/код:
`Namer`, `QBittorrent`, `notifyFailed`/`SetFailureNotifier`, `qbit_add`-путь
падения add (падение теперь у worker'а — п.4)
- [x] 2.3 Обновить тесты ingest: приём создаёт `catched`, не зовёт qBit/namer;
дедуп по активной (в т.ч. `catched`); синтез контекста сохраняется как прежде
- [x] 2.4 Транспорты (`httpapi`, `tgbot`): ответ по `Result` корректен для
`catched` (без правок API); Telegram-текст не показывает сырое `catched`
(дружелюбная формулировка «принято/добавляется»)
## 3. Асинхронный шаг добавления (worker)
- [x] 3.1 Внедрить в worker зависимости `Namer` и `qbt.Add`; добавить в
интерфейс `worker.Store` новый метод записи имени (`SetDisplayName`)
- [x] 3.2 Шаг в поллинг-цикле, сеть ВНЕ `w.mu`: под замком снять
`ListDownloadsByState(StateCatched)`; вне замка для каждой — разобрать
`d.SourceRef` (`magnet.Parse`) для dn-hint, вывести имя (`namer.DeriveName`),
вызвать `qbt.Add(urls=d.SourceRef, category, savepath, rename)`; снова под
замком — ре-валидировать `state==catched` и записать переход `catched →
downloading` + `display_name`
- [x] 3.3 Сбой `add`: оставить в `catched` (повтор на следующем тике), не уводить
в терминальное по единичному сбою; логировать (ext.* уже логирует клиент)
- [x] 3.4 Тесты worker: `catched → downloading` при успехе (rename передан,
display_name сохранён, сетевые вызовы вне замка); транзиентный сбой оставляет
`catched` и повторяет; отмена во время add (ре-валидация отбрасывает переход)
## 4. Предохранитель catch_timeout
- [x] 4.1 Конфиг `catch_timeout` (+ дефолт, валидация на старте) — по образцу
`magnet_timeout`; документация конфига
- [x] 4.2 В поллинг-цикле: `catched` старше `catch_timeout` (от создания) →
`failed` (`error_code` `qbit_add`) + уведомление автора
- [x] 4.3 Тест: `catched` за таймаутом → `failed` + notify
## 5. Исключение catched из проверок пропажи
- [x] 5.1 Убедиться (и закрепить тестом), что поллинг активных и сверка
рассинхрона (`state-reconciliation`) не трактуют `catched` как пропажу/
orphaned
- [x] 5.2 Тест на гонку discover ↔ шаг добавления: дубль по infohash не заводится
## 6. Веб-UI
- [x] 6.1 Человекочитаемая подпись бейджа для `catched` (`badgeLabel`), чтобы не
показывать сырое `catched`; `catched` в активной группе списка
- [x] 6.2 Заголовок при пустом `display_name` (фолбек) и отсутствие секции
раздачи/живого прогресса для `catched` без ошибок
- [x] 6.3 Самозавершающийся htmx-поллинг карточки/страницы в `catched`: пока
`catched` — опрашивает фрагмент; по переходе в `downloading` показывает
прогресс/имя без перезагрузки и завершает поллинг фазы (правка live-рендера
для не-`downloading` активной фазы)
- [x] 6.4 Тест httpapi/шаблонов: карточка/страница `catched` рендерится;
фрагмент-поллинг отдаётся для `catched` и завершается после перехода
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate fast-catch-ingest --strict` проходит
- [x] 7.3 Прогон вручную/через verify: приём отвечает быстро (без LLM в пути),
загрузка проходит `catched → downloading`, карточка обновляется без
перезагрузки
- [x] 7.4 Рантбук отката: снять застрявшие `catched` (`UPDATE download SET
state='failed', error_code='qbit_add' WHERE state='catched'`) — зафиксировать
в описании change/задаче
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-07

Some files were not shown because too many files have changed in this diff Show More