Compare commits
11
Commits
576fc4e6c0
...
b1bca98738
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bca98738
|
||
|
|
a43adea723
|
||
|
|
2ed42b3e18
|
||
|
|
8ad4273db2
|
||
|
|
7a774ad53d
|
||
|
|
14d615a7c2
|
||
|
|
90fd8640ed
|
||
|
|
0d263270cb
|
||
|
|
f0ce6b4bc8
|
||
|
|
e9ec26d09a
|
||
|
|
6d801ed03b
|
@@ -91,6 +91,22 @@ OpenSpec (пилот — `ingest`). До переноса источник ис
|
||||
- `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) в
|
||||
файл (`config.toml` не коммитится, `0600`), не в env; валидация на старте:
|
||||
[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`, без числовых
|
||||
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе:
|
||||
[docs/conventions/database.md](docs/conventions/database.md).
|
||||
|
||||
@@ -80,13 +80,13 @@ func runServe(args []string) error {
|
||||
}
|
||||
|
||||
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
|
||||
// работает только алгоритмический фолбек.
|
||||
// работает только алгоритмический фолбек. Namer зовёт worker на шаге
|
||||
// добавления пойманной загрузки (не синхронный приём).
|
||||
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
|
||||
|
||||
ingestor := ingest.New(st, qb, namer, ingest.Config{
|
||||
Category: cfg.QBittorrent.Category,
|
||||
SavePath: cfg.QBittorrent.SavePath,
|
||||
}, logger)
|
||||
// Быстрый приём: сохраняет загрузку в catched и сразу отвечает; добавление в
|
||||
// qBittorrent и вывод имени делает worker (см. download-tracking).
|
||||
ingestor := ingest.New(st, logger)
|
||||
|
||||
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
||||
providers, err := metadataProviders(cfg, logger)
|
||||
@@ -126,8 +126,11 @@ func runServe(args []string) error {
|
||||
PollInterval: cfg.Worker.PollInterval.Std(),
|
||||
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
||||
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
||||
CatchTimeout: cfg.Worker.CatchTimeout.Std(),
|
||||
SourceMissingThreshold: cfg.Worker.SourceMissingThreshold,
|
||||
}, logger)
|
||||
// Вывод имени на шаге добавления пойманной загрузки (best-effort).
|
||||
wrk.SetNamer(namer)
|
||||
|
||||
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
||||
// не валит сервис — скан просто не сработает (залогируется в воркере).
|
||||
@@ -188,10 +191,6 @@ func runServe(args []string) error {
|
||||
WebBaseURL: cfg.Telegram.WebBaseURL,
|
||||
}, logger)
|
||||
wrk.SetNotifier(bot)
|
||||
// Приёмные падения (qbit_add) минуют worker — уведомляем напрямую.
|
||||
ingestor.SetFailureNotifier(func(id string) {
|
||||
bot.Notify(context.Background(), id, worker.EventFailed)
|
||||
})
|
||||
go bot.Run(ctx)
|
||||
logger.Info("telegram bot enabled",
|
||||
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
|
||||
|
||||
@@ -67,6 +67,7 @@ timeout = "10s" # таймаут запроса к Jellyfin
|
||||
poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h)
|
||||
stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration
|
||||
magnet_timeout = "24h" # страховочный предел ожидания метаданных magnet (не рабочий механизм: ожившие задачи воскрешаются сверкой); Go-duration
|
||||
catch_timeout = "10m" # страховочный предел: пойманная (catched) задача не добавилась в qBittorrent за это время → failed; Go-duration
|
||||
source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс)
|
||||
|
||||
[recognition]
|
||||
|
||||
-484
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Авторизация веб-UI (на будущее)
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
Решено для v1: без авторизации в доверенной LAN, опц. allowlist подсетей (http.trusted_subnets) — как умеет qBittorrent. Если понадобится защита: токен/Basic в самом приложении или вынос за reverse-proxy с аутентификацией.
|
||||
|
||||
Связано: specs/architecture.md → «Транспорты» (доступ к веб-UI), пакет httpapi.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Бэкап SQLite
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
architecture.md требует «бекапить data-том», но как — не описано. Без понятной стратегии сбой или редеплой стирают всё in-flight состояние. Зафиксировать решение и реализовать: периодический VACUUM INTO в /data/backups по расписанию (с ротацией) либо потоковая репликация (litestream). Лучше сделать, пока БД маленькая.
|
||||
|
||||
Связано: specs/architecture.md → «Деплой» (data-том), пакет store.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# [идея] guessit как сервис-спутник
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
ИДЕЯ. go-ptn слабее питоновского guessit. Если точности пред-парса не хватит — завернуть guessit в крошечный HTTP-сервис (один файл, поставляется рядом с бинарём jellybit) и спрашивать его на шаге пред-парса. Сохраняет «доставку копированием»: два файла вместо одного.
|
||||
|
||||
Связано: specs/recognition.md → «На будущее» (пред-парс).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Кэш метабаз (и опционально LLM)
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
Повторные и ретраящиеся прогоны распознавания бьют TMDB/TVDB/TVMaze одним и тем же запросом. Кэш ответов с TTL экономит лимиты API и ускоряет «Распознать заново»/«Уточнить». При желании — кэш ответов LLM по хешу входа (но он менее полезен, т.к. вход меняется подсказками).
|
||||
|
||||
Связано: specs/recognition.md (сверка с базой), пакеты metadata, llm.
|
||||
@@ -0,0 +1,7 @@
|
||||
# НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Потолок по нагрузке нигде не зафиксирован: воркер, поллинг qBittorrent, пул LLM-вызовов и запись в SQLite спроектированы «на глаз». Записать в НФТ целевой ориентир — архитектура держит до 100 одновременных загрузок в работе (приём → распознавание → раскладка), план-максимум — 1000. Сама запись требования дешева и высокоценна: задаёт рамку для решений ниже. Отдельно (дороже) — аудит узких мест: одиночное соединение SQLite и сериализация записи, конкурентность воркера и лимит параллельных распознаваний, частота/стоимость поллинга и дедуп при наплыве.
|
||||
|
||||
Связано: specs/architecture.md → «Отслеживание загрузки»/«Хранилище», пакеты worker, store, qbt, llm.
|
||||
@@ -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 (конвейер и модель уверенности).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Обучение на правках человека (few-shot из прошлых ревью)
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать похожие в будущие промпты. Системно повышает точность на «твоих» трекерах и форматах имён без смены модели. Развитие идеи многоступенчатой верификации, но дешевле: учимся на уже собранных hint/override.
|
||||
|
||||
Связано: specs/recognition.md (конвейер, промпт), «Многоступенчатая верификация», specs/architecture.md → «Хранилище» (hint, override).
|
||||
@@ -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.
|
||||
@@ -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. Фикс: рунобезопасная обрезка.
|
||||
|
||||
Вердикт: все простые фиксы.
|
||||
@@ -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-путей там не найдено.)
|
||||
|
||||
Вердикт: простые фиксы/принять.
|
||||
@@ -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=now−added_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, «Проблема второго сезона», «Раздачи с докачиванием».
|
||||
@@ -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.
|
||||
@@ -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 → «Транспорты».
|
||||
@@ -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, новый файл-глоссарий.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Привязка уведомлений к источнику в ботах (мульти-бот)
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней. Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся единым для всех и точкой правды по функциональности (боты — тонкие адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и идентификатор отправителя, маршрутизировать пинги по нему.
|
||||
|
||||
Связано: specs/review-ux.md, specs/architecture.md → «Транспорты».
|
||||
@@ -0,0 +1,7 @@
|
||||
# Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
||||
|
||||
**Приоритет:** низкий
|
||||
|
||||
По калибровке болей (2026-07-02) — не боль, из приоритета выпало. Сосуществование версий доступно уже сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же путь штатно уходит в review. Явный replace (undo старого хардлинка → lay нового → супересид владения путём) — отдельный change, если/когда станет болью.
|
||||
|
||||
Связано: «Раздачи с докачиванием», specs/jellyfin-layout.md (never-overwrite, коллизия).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -61,7 +61,8 @@ reject / defer / undo) — команды к `worker`:
|
||||
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
|
||||
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||||
деплой только в локальную сеть без доступа из интернета, поэтому
|
||||
allowlist-middleware и авторизацию отложили — [backlog.md](../backlog.md#авторизация-веб-ui-на-будущее).
|
||||
allowlist-middleware и авторизацию отложили — задача
|
||||
[«Авторизация веб-UI»](../backlog/avtorizaciya-web-ui.md) в беклоге.
|
||||
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
||||
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
||||
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
||||
|
||||
+12
-1
@@ -13,7 +13,7 @@
|
||||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`,
|
||||
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
|
||||
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
|
||||
> `DEFAULT` убран).
|
||||
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла).
|
||||
|
||||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||
@@ -27,6 +27,7 @@
|
||||
```mermaid
|
||||
erDiagram
|
||||
download ||--o{ download_infohash : "инфохэши (v1/v2)"
|
||||
download ||--o| download_torrent : "байты .torrent (1:0..1)"
|
||||
download ||--o{ recognition : "распознавания"
|
||||
download ||--o{ hint : "подсказки"
|
||||
download ||--o{ override : "ручные правки"
|
||||
@@ -55,6 +56,11 @@ erDiagram
|
||||
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 {
|
||||
TEXT id PK "ULID"
|
||||
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
|
||||
@@ -125,6 +131,11 @@ erDiagram
|
||||
более одной активной загрузки на infohash» держат guarded-методы store
|
||||
(`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной
|
||||
write-транзакции — на уровне схемы он не выражается (условие на `state`).
|
||||
- `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только
|
||||
у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для
|
||||
повторного добавления при retry, поэтому живут весь срок строки загрузки.
|
||||
`ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не
|
||||
удаляются).
|
||||
- `download` ↔ `file_link` — один источник (раздача) ко многим разложенным
|
||||
файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не
|
||||
каждый файл раздачи попадает в `file_link` (только распознанные медиа и
|
||||
|
||||
@@ -99,4 +99,4 @@ inode общий — диск не дублируется.
|
||||
сезонам.
|
||||
- **Несколько аудиодорожек** — обычно внутри mkv, не наша забота.
|
||||
- **Аниме с абсолютной нумерацией** — пересчёт в S·E, отдельная проработка
|
||||
([backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)).
|
||||
([задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md)).
|
||||
|
||||
@@ -133,11 +133,12 @@ notes пояснения, неоднозначности
|
||||
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
|
||||
00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
|
||||
любая неоднозначность → review.
|
||||
- Аниме с абсолютной нумерацией — отдельный крайний случай, см.
|
||||
[backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией).
|
||||
- Аниме с абсолютной нумерацией — отдельный крайний случай,
|
||||
[задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md).
|
||||
|
||||
## На будущее
|
||||
|
||||
`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
|
||||
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
|
||||
бинарём). См. [backlog.md](../backlog.md#guessit-как-сервис-спутник-идея).
|
||||
бинарём). Задача [«guessit как сервис-спутник»](../backlog/guessit-sputnik.md)
|
||||
в беклоге.
|
||||
|
||||
@@ -119,8 +119,8 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
- **База неоднозначна** → выбор кандидата (часто чинит всё разом: пиннит
|
||||
provider-id и каноническое имя).
|
||||
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
|
||||
абсолютной нумерацией → веб-хелпер «absolute → S·E» (см.
|
||||
[backlog.md](../backlog.md#аниме-с-абсолютной-нумерацией)).
|
||||
абсолютной нумерацией → веб-хелпер «absolute → S·E»
|
||||
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
|
||||
- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана.
|
||||
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
|
||||
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
|
||||
|
||||
@@ -3,6 +3,7 @@ module git.vakhrushev.me/av/jellybit
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/anacrolix/torrent v1.61.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/jmoiron/sqlx v1.4.0
|
||||
@@ -14,17 +15,30 @@ 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/google/uuid v1.6.0 // 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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.44.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/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
@@ -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/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/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/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/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/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/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/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
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/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/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/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/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/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/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/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/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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/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/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/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/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/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/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/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
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/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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/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/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
@@ -111,6 +111,10 @@ type Worker struct {
|
||||
PollInterval Duration `toml:"poll_interval"`
|
||||
StuckAfter Duration `toml:"stuck_after"`
|
||||
MagnetTimeout Duration `toml:"magnet_timeout"`
|
||||
// CatchTimeout — сколько пойманная (catched) загрузка может ждать добавления
|
||||
// в qBittorrent, прежде чем счесть его невозможным и увести задачу в failed.
|
||||
// Редкий предохранитель на случай устойчивой недоступности qBittorrent.
|
||||
CatchTimeout Duration `toml:"catch_timeout"`
|
||||
// SourceMissingThreshold — сколько подряд тиков сверки без раздачи в
|
||||
// qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи,
|
||||
// см. state-reconciliation). Любое появление раздачи сбрасывает счётчик.
|
||||
@@ -208,6 +212,7 @@ func Default() *Config {
|
||||
PollInterval: Duration(5 * time.Second),
|
||||
StuckAfter: Duration(time.Hour),
|
||||
MagnetTimeout: Duration(24 * time.Hour),
|
||||
CatchTimeout: Duration(10 * time.Minute),
|
||||
SourceMissingThreshold: 3,
|
||||
},
|
||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
||||
|
||||
@@ -20,6 +20,7 @@ type downloadDetailView struct {
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
SelfPoll bool // catched → страница сама опрашивает себя до перехода
|
||||
Error string
|
||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||
Note string
|
||||
@@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
Error: d.ErrorMsg.String,
|
||||
Note: desyncNote(d.State),
|
||||
CreatedAt: d.CreatedAt,
|
||||
|
||||
+41
-10
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"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/web"
|
||||
)
|
||||
@@ -113,6 +115,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||||
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
|
||||
// имя и появившийся прогресс обновляются без перезагрузки).
|
||||
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
@@ -195,6 +200,7 @@ type downloadView struct {
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
@@ -391,14 +397,34 @@ func pageWindow(page, pages int) []int {
|
||||
}
|
||||
|
||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectErr(w, r, "не удалось разобрать форму")
|
||||
// Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
|
||||
// небольшой запас на прочие поля. 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
|
||||
}
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
||||
Source: r.PostForm.Get("source"),
|
||||
Context: r.PostForm.Get("context"),
|
||||
})
|
||||
|
||||
req := ingest.Request{
|
||||
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 {
|
||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||
return
|
||||
@@ -618,6 +644,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
IsDownloading: d.State == store.StateDownloading,
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
@@ -720,15 +747,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||
// деталей реализации): ErrNotFound → 404, валидация источника
|
||||
// (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409,
|
||||
// прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём
|
||||
// только сообщение + корреляционный ключ.
|
||||
// (magnet.ErrNotMagnet) → 400, недокачанный источник (worker.ErrNotReady) и
|
||||
// конфликт состояния (worker.ErrConflict) → 409, прочее → 500. Полная ошибка
|
||||
// уже в логах на доменной границе — наружу отдаём только сообщение +
|
||||
// корреляционный ключ.
|
||||
func classifyErr(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound, "не найдено"
|
||||
case errors.Is(err, magnet.ErrNotMagnet):
|
||||
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||
return http.StatusBadRequest, "некорректный источник"
|
||||
case errors.Is(err, worker.ErrNotReady):
|
||||
// Источник ещё качается — actionable причина, показываем конкретно.
|
||||
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
|
||||
@@ -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) {
|
||||
reader := &fakeReader{list: []store.Download{
|
||||
{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
|
||||
|
||||
func (e ingestErr) Error() string { return string(e) }
|
||||
|
||||
@@ -96,6 +96,26 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
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-поллинг).
|
||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
|
||||
@@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler {
|
||||
// как есть, чтобы не терять его в UI.
|
||||
func badgeLabel(state string) string {
|
||||
switch state {
|
||||
case "catched":
|
||||
return "🎣 принято, добавляется"
|
||||
case "downloading":
|
||||
return "⬇ качается"
|
||||
case "completed":
|
||||
|
||||
@@ -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
@@ -1,6 +1,9 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов
|
||||
// (HTTP, Telegram, CLI). Принимает источник + контекст, отдаёт источник в
|
||||
// qBittorrent и заводит/находит задачу в БД.
|
||||
// Package ingest — use-case быстрого приёма загрузки, общий для всех
|
||||
// транспортов (HTTP, Telegram, CLI). Синхронно только парсит источник,
|
||||
// синтезирует контекст из полей ссылки, дедуплицирует и сохраняет загрузку в
|
||||
// состоянии `catched`, сразу возвращая ответ. Вывод отображаемого имени
|
||||
// (медленный LLM) и добавление в qBittorrent — отдельный асинхронный шаг
|
||||
// worker'а (см. download-tracking).
|
||||
package ingest
|
||||
|
||||
import (
|
||||
@@ -11,76 +14,46 @@ import (
|
||||
|
||||
"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/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
)
|
||||
|
||||
// capIngest — стадия приёма для поля capability в логах.
|
||||
const capIngest = "ingest"
|
||||
|
||||
// errCodeQbitAdd — error_code задачи, упавшей на добавлении источника в
|
||||
// qBittorrent (раздачи в qBittorrent нет, восстановлению не подлежит).
|
||||
const errCodeQbitAdd = "qbit_add"
|
||||
|
||||
// Store — нужная ingest часть хранилища.
|
||||
type Store interface {
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
|
||||
// авторитетная проверка — внутри CreateDownloadIfNoActive.
|
||||
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
|
||||
// внутри CreateDownloadIfNoActive.
|
||||
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на 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). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
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.
|
||||
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 — реализация приёма.
|
||||
// Service — реализация быстрого приёма.
|
||||
type Service struct {
|
||||
store Store
|
||||
qbt QBittorrent
|
||||
namer Namer
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
// notifyFailed — опц. пинг автору о падении приёма (добавление в qBittorrent
|
||||
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
|
||||
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
|
||||
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
|
||||
notifyFailed func(downloadID string)
|
||||
}
|
||||
|
||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||
// выводится; qBittorrent оставит своё).
|
||||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
||||
// New собирает сервис приёма.
|
||||
func New(st Store, log *slog.Logger) *Service {
|
||||
return &Service{store: st, log: log}
|
||||
}
|
||||
|
||||
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
|
||||
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
// Request — входной запрос приёма. Источник задаётся либо строкой (magnet), либо
|
||||
// байтами `.torrent` (TorrentData); при непустом TorrentData он в приоритете.
|
||||
type Request struct {
|
||||
Source string // пока — magnet-ссылка
|
||||
Source string // magnet-ссылка (когда TorrentData пуст)
|
||||
TorrentData []byte // байты .torrent-файла (опц.); при наличии — источник torrent
|
||||
TorrentName string // имя загруженного файла (опц.); фолбек для source_ref
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
}
|
||||
|
||||
@@ -92,50 +65,44 @@ type Result struct {
|
||||
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
|
||||
}
|
||||
|
||||
// Ingest принимает источник: извлекает infohash, дедуплицирует по активной
|
||||
// задаче, иначе заводит задачу и отдаёт источник в qBittorrent.
|
||||
// Ingest быстро принимает источник: извлекает infohash, синтезирует контекст из
|
||||
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет worker (см. download-tracking).
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
src, err := s.parse(req)
|
||||
if err != nil {
|
||||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
||||
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||
// Кладём в ctx, чтобы внешние клиенты (qBittorrent, LLM-namer) дописывали
|
||||
// ключи корреляции к своим ext.*-записям сами.
|
||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
|
||||
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
|
||||
// хешей источника: гибридный magnet несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return s.attached(ctx, info, 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)
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
|
||||
// Контекст распознавания дополняем фактами из полей источника (magnet-поля
|
||||
// или дерево `.torrent`) — без сети. Пользовательский текст идёт первым.
|
||||
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||
Context: req.Context,
|
||||
State: store.StateDownloading,
|
||||
SourceType: src.sourceType,
|
||||
SourceRef: src.sourceRef,
|
||||
Context: mergeContext(req.Context, src.synthContext),
|
||||
State: store.StateCatched,
|
||||
}
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||
// torrentBlob непуст только для source_type=torrent — пишется в той же
|
||||
// транзакции лишь на ветке создания.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
|
||||
if err != nil {
|
||||
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)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
}
|
||||
id := d.ID
|
||||
log = log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
addErr := s.qbt.Add(ctx, qbt.AddRequest{
|
||||
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)
|
||||
log.Info("download catched", "download_id", d.ID)
|
||||
return Result{
|
||||
DownloadID: id,
|
||||
Infohashes: info.Infohashes,
|
||||
State: store.StateDownloading,
|
||||
DownloadID: d.ID,
|
||||
Infohashes: src.infohashes,
|
||||
State: store.StateCatched,
|
||||
}, 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 — итог дедупа на быстром чеке: присоединились к уже активной
|
||||
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||
// хеша с другой активной задачей логируется, приём не валится.
|
||||
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
||||
if len(info.Infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
||||
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||
if len(src.infohashes) > len(existing.Infohashes) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}
|
||||
|
||||
+66
-133
@@ -2,14 +2,12 @@ package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
@@ -21,28 +19,22 @@ type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
blobs [][]byte
|
||||
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) {
|
||||
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 {
|
||||
return f.active, nil
|
||||
}
|
||||
d.ID = ident.NewID()
|
||||
f.created = append(f.created, *d)
|
||||
f.hashes = append(f.hashes, hashes)
|
||||
f.blobs = append(f.blobs, torrentBlob)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -52,121 +44,101 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||
return nil
|
||||
func newService(st Store) *Service {
|
||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
type fakeQbt struct {
|
||||
added []qbt.AddRequest
|
||||
err error
|
||||
}
|
||||
|
||||
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) {
|
||||
// Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и
|
||||
// вывод имени в пути приёма не участвуют (это делает worker).
|
||||
func TestIngestCatchesFast(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||
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)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" {
|
||||
t.Errorf("сохранённая задача: %+v", got)
|
||||
got := fs.created[0]
|
||||
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 {
|
||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||
}
|
||||
if len(fq.added) != 1 {
|
||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||
}
|
||||
add := fq.added[0]
|
||||
if len(add.URLs) != 1 || add.URLs[0] != sampleMagnet {
|
||||
t.Errorf("URLs = %v", add.URLs)
|
||||
|
||||
// Голый 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)
|
||||
}
|
||||
if add.Category != "jellybit" || add.SavePath != "/srv/media/downloads" {
|
||||
t.Errorf("category/savepath = %q/%q", add.Category, add.SavePath)
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
ctx := fs.created[0].Context
|
||||
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{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||||
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" {
|
||||
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called)
|
||||
got := fs.created[0].Context
|
||||
if !strings.Contains(got, "t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён доменом трекера: %q", got)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
// То же имя сохраняется у загрузки — заголовок в веб-UI.
|
||||
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
|
||||
if strings.Contains(got, "rutracker-topic") {
|
||||
t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: ""} // имя не получено
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet}); err != nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: raw}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "" {
|
||||
t.Errorf("rename = %q, want пусто", fq.added[0].Rename)
|
||||
ctx := fs.created[0].Context
|
||||
if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") {
|
||||
t.Errorf("download.Context не обогащён: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
@@ -176,9 +148,6 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно создаваться новой задачи")
|
||||
}
|
||||
if len(fq.added) != 0 {
|
||||
t.Error("не должно быть повторного добавления в qBittorrent")
|
||||
}
|
||||
}
|
||||
|
||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||
@@ -187,11 +156,11 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
existing := &store.Download{
|
||||
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
||||
ID: "01hzzzexisting000000000000", State: store.StateCatched,
|
||||
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||
}
|
||||
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})
|
||||
if err != nil {
|
||||
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) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
if _, err := newService(fs, fq).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||
t.Fatal("ожидалась ошибка для не-magnet источника")
|
||||
}
|
||||
if len(fs.created) != 0 || len(fq.added) != 0 {
|
||||
t.Error("не должно быть ни записи, ни добавления")
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно быть записи задачи")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -11,6 +11,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -20,6 +22,9 @@ type Info struct {
|
||||
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
|
||||
DisplayName string // dn — человекочитаемое имя, если задано
|
||||
Trackers []string // tr — трекеры
|
||||
Sources []string // xs — exact source (ссылки/источники)
|
||||
Keywords []string // kt — ключевые слова
|
||||
ExactLength int64 // xl — размер в байтах; 0, если не задан/некорректен
|
||||
}
|
||||
|
||||
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
|
||||
@@ -29,9 +34,8 @@ var ErrNotMagnet = errors.New("not a magnet link")
|
||||
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
|
||||
// предпочитается v1.
|
||||
func Parse(raw string) (Info, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(u.Scheme, "magnet") {
|
||||
u, ok := magnetURL(strings.TrimSpace(raw))
|
||||
if !ok {
|
||||
return Info{}, ErrNotMagnet
|
||||
}
|
||||
vals := u.Query()
|
||||
@@ -60,14 +64,41 @@ func Parse(raw string) (Info, error) {
|
||||
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{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: vals.Get("dn"),
|
||||
Trackers: vals["tr"],
|
||||
Sources: vals["xs"],
|
||||
Keywords: vals["kt"],
|
||||
ExactLength: xl,
|
||||
}, 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.
|
||||
func normalizeBTIH(h string) (string, error) {
|
||||
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 имеет вид
|
||||
// 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в
|
||||
// infohash_v2).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package magnet
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
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) {
|
||||
lower := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
upper := "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6"
|
||||
|
||||
+157
-2
@@ -20,6 +20,7 @@ import (
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent
|
||||
StateDownloading State = "downloading"
|
||||
StateCompleted State = "completed"
|
||||
StateRecognizing State = "recognizing" // Ф2
|
||||
@@ -63,6 +64,71 @@ func (s State) IsTerminal() bool {
|
||||
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 — вид источника загрузки.
|
||||
type SourceType string
|
||||
|
||||
@@ -180,7 +246,12 @@ func NullString(s string) sql.NullString {
|
||||
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
||||
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
||||
// 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)
|
||||
if len(norm) == 0 {
|
||||
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)})
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("create download: commit: %w", err)
|
||||
}
|
||||
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 атомарно возвращает загрузку в активное состояние
|
||||
// (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)
|
||||
}
|
||||
|
||||
// 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 — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
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 := `
|
||||
UPDATE download
|
||||
SET state = ?,
|
||||
@@ -515,6 +642,10 @@ SET state = ?,
|
||||
updated_at = ?
|
||||
WHERE 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() {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 записывает счётчик пропусков источника (дебаунс сверки).
|
||||
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||||
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
|
||||
|
||||
@@ -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; дедуп на
|
||||
// существующую активную — ошибка теста.
|
||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
t.Helper()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
@@ -42,6 +100,19 @@ func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
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) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -108,9 +179,7 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
id := mustCreate(t, store, ih)
|
||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, store, id, st)
|
||||
if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||
t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err)
|
||||
}
|
||||
@@ -160,7 +229,7 @@ func TestActiveDuplicateDeduplicated(t *testing.T) {
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -185,7 +254,7 @@ func TestDedupByAnyHash(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -233,9 +302,7 @@ func TestActivateIfNoOtherActive(t *testing.T) {
|
||||
}
|
||||
|
||||
// Владелец завершился → активация проходит.
|
||||
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, id2, StateDone)
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("активация после ухода владельца: %v", err)
|
||||
}
|
||||
@@ -255,7 +322,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
id := mustCreate(t, st, v1)
|
||||
|
||||
// Гибридный вызов с {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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -266,7 +333,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
||||
}
|
||||
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,9 +363,7 @@ func TestAddInfohashesGuard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Хеш терминального владельца дописывается свободно.
|
||||
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, a, StateDone)
|
||||
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
|
||||
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
|
||||
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
|
||||
@@ -342,16 +407,14 @@ func TestSetDownloadStateRejectsRevive(t *testing.T) {
|
||||
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Терминал→активное обычным SetDownloadState отклоняется (revive-гард): ребро
|
||||
// failed → downloading в графе есть, но проходит только через гард владения.
|
||||
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
|
||||
t.Fatal("терминал→активное мимо гарда должно отклоняться")
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
|
||||
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 {
|
||||
t.Fatalf("оживление через гард: %v", err)
|
||||
@@ -374,7 +437,7 @@ func TestConcurrentCreateDedup(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
@@ -414,9 +477,7 @@ func TestExistsByInfohash(t *testing.T) {
|
||||
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forceState(t, st, id, StateDone)
|
||||
// Exists видит и терминальные (в отличие от FindActive).
|
||||
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
|
||||
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
|
||||
|
||||
@@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State {
|
||||
case GroupReview:
|
||||
return []State{StateReview, StateDeferred}
|
||||
case GroupActive:
|
||||
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||
return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||
case GroupDone:
|
||||
return []State{StateDone}
|
||||
case GroupProblem:
|
||||
|
||||
@@ -16,7 +16,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
|
||||
ctx := context.Background()
|
||||
d := newDownloading()
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
forceState(t, st, d.ID, state)
|
||||
}
|
||||
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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -2,10 +2,15 @@ package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
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)
|
||||
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
|
||||
StopReceivingUpdates()
|
||||
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
|
||||
// присланного .torrent-документа).
|
||||
GetFileDirectURL(fileID string) (string, error)
|
||||
}
|
||||
|
||||
// Ingestor принимает загрузку (ingest.Service).
|
||||
@@ -56,6 +64,8 @@ type Bot struct {
|
||||
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||
|
||||
httpClient *http.Client // для скачивания .torrent-документов 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, "/"),
|
||||
log: log,
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
|
||||
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
|
||||
// подписи (m.Caption).
|
||||
if m.Document != nil {
|
||||
b.handleDocument(ctx, m)
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(m.Text)
|
||||
|
||||
// Ждём подсказку для перераспознавания?
|
||||
@@ -140,25 +160,113 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
|
||||
source, context, ok := ParseMessage(text)
|
||||
if !ok {
|
||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
|
||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
||||
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 {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||
// него (детали всё равно в логах на доменной границе).
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
||||
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 errors.Is(err, worker.ErrNotReady) {
|
||||
// Источник ещё качается — actionable причина, показываем конкретно.
|
||||
b.answer(cq.ID, "Торрент ещё качается")
|
||||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||||
return
|
||||
}
|
||||
b.answer(cq.ID, "Ошибка")
|
||||
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
|
||||
return
|
||||
|
||||
@@ -22,6 +22,8 @@ type fakeAPI struct {
|
||||
sent []sentMsg
|
||||
edits []sentMsg
|
||||
answers []string
|
||||
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
|
||||
fileErr error
|
||||
}
|
||||
|
||||
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) StopReceivingUpdates() {}
|
||||
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
|
||||
|
||||
type fakeIngestor struct {
|
||||
lastReq ingest.Request
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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), " ")
|
||||
}
|
||||
@@ -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() // не паникует; состав строк — по полям
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes, nil)
|
||||
if err != nil {
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
|
||||
@@ -6,3 +6,10 @@ import "errors"
|
||||
// вне review/deferred, undo вне done). Это нормальный конфликт состояния, а не
|
||||
// сбой сервера: транспорт матчит его через errors.Is и отвечает 409, не 500.
|
||||
var ErrConflict = errors.New("conflict")
|
||||
|
||||
// ErrNotReady — источник (раздача) присутствует в qBittorrent, но ещё не
|
||||
// докачан (не в классе classReady), поэтому команда, ведущая к распознаванию
|
||||
// или раскладке, отклонена: хардлинки на неполные файлы недопустимы. Отдельно
|
||||
// от ErrConflict (тоже 409), потому что причина actionable — «дождись докачки»
|
||||
// — и транспорт показывает её конкретным текстом, а не генериком конфликта.
|
||||
var ErrNotReady = errors.New("source not ready")
|
||||
|
||||
@@ -245,24 +245,34 @@ func recoveredState(state string) store.State {
|
||||
|
||||
// --- Синхронный preflight перед действием (не доверяем state в БД) ---
|
||||
|
||||
// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в
|
||||
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
||||
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
||||
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
||||
// ensureSourceReady синхронно (без дебаунса) проверяет, что раздача есть в
|
||||
// qBittorrent прямо сейчас И докачана (класс classReady). Команды ревью, ведущие
|
||||
// к распознаванию или раскладке, работают только с готовым источником — иначе
|
||||
// хардлинки легли бы на неполные файлы (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 {
|
||||
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 {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
// Источник пропал — немедленно приводим состояние к реальности.
|
||||
w.reconcileToReality(ctx, *d, false)
|
||||
return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict)
|
||||
}
|
||||
if classify(t.State) != classReady {
|
||||
return fmt.Errorf("%s: торрент ещё качается: %w", op, ErrNotReady)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileToReality выводит и проставляет состояние по уже известному факту об
|
||||
// источнике (sourcePresent) и фактически проверенной цели. Используется
|
||||
|
||||
@@ -41,7 +41,9 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
|
||||
|
||||
var torrents []qbt.Torrent
|
||||
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.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки)
|
||||
|
||||
@@ -247,6 +247,11 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
w.reconcileToReality(ctx, *d, false)
|
||||
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, "", "")
|
||||
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) задачу: возвращает её на распознавание, и поллинг-цикл
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
|
||||
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
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 {
|
||||
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
|
||||
}
|
||||
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
|
||||
@@ -366,7 +372,7 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
|
||||
if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
|
||||
if err := w.ensureSourceReady(ctx, d, "refine"); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
|
||||
if err := w.ensureSourceReady(ctx, d, "set type"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -106,7 +108,7 @@ func revertedDownload(id string) *store.Download {
|
||||
func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
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)
|
||||
|
||||
if err := w.Relink(context.Background(), "1"); err != nil {
|
||||
@@ -125,7 +127,7 @@ func TestRelink_CancelledToRecognizing(t *testing.T) {
|
||||
d := revertedDownload("1")
|
||||
d.State = store.StateCancelled
|
||||
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)
|
||||
|
||||
if err := w.Relink(context.Background(), "1"); err != nil {
|
||||
@@ -155,7 +157,7 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
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)
|
||||
|
||||
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.
|
||||
type memStore struct {
|
||||
downloads map[string]*store.Download
|
||||
@@ -226,6 +309,7 @@ type memStore struct {
|
||||
overrides map[string]map[string]string
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
torrents map[string][]byte
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -285,7 +369,7 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*s
|
||||
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 {
|
||||
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)})
|
||||
}
|
||||
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.Infohashes = cp.Infohashes
|
||||
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 {
|
||||
d, ok := m.downloads[id]
|
||||
if !ok {
|
||||
@@ -346,6 +443,16 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
|
||||
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 {
|
||||
if d, ok := m.downloads[id]; ok {
|
||||
d.SourceMissCount = n
|
||||
@@ -666,7 +773,7 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
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)
|
||||
|
||||
if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
|
||||
@@ -688,7 +795,7 @@ func TestSetType(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
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)
|
||||
|
||||
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{
|
||||
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)
|
||||
|
||||
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
|
||||
|
||||
@@ -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
@@ -24,9 +24,11 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"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/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
)
|
||||
|
||||
// Стадии (capability) — адресуют запись к подсистеме при корреляции по
|
||||
@@ -44,12 +46,18 @@ type Store interface {
|
||||
ListRecoverable(ctx context.Context, codes ...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
|
||||
// PromoteCatched атомарно переводит catched → downloading с записью имени
|
||||
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
|
||||
PromoteCatched(ctx context.Context, id, displayName string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer).
|
||||
// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Layouter — раскладчик хардлинками (layout.Layouter).
|
||||
type Layouter interface {
|
||||
BuildLinks(p layout.Plan) ([]layout.Link, error)
|
||||
@@ -111,6 +125,10 @@ const (
|
||||
errCodeMagnetTimeout = "magnet_timeout"
|
||||
errCodeStalled = "stalled"
|
||||
errCodeQbitError = "qbit_error"
|
||||
// errCodeQbitAdd — не удалось добавить пойманную загрузку в qBittorrent за
|
||||
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
|
||||
// восстановлению сверкой не подлежит.
|
||||
errCodeQbitAdd = "qbit_add"
|
||||
)
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
@@ -134,6 +152,7 @@ type Config struct {
|
||||
PollInterval time.Duration
|
||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||
CatchTimeout time.Duration // catched дольше (не удалось добавить в qBit) → failed
|
||||
// SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки).
|
||||
// <1 трактуется как 1 (помечаем при первой же устойчивой пропаже).
|
||||
SourceMissingThreshold int
|
||||
@@ -181,6 +200,7 @@ type Worker struct {
|
||||
qbt QBittorrent
|
||||
recognizer Recognizer
|
||||
layouter Layouter
|
||||
namer Namer // опц. вывод отображаемого имени на шаге добавления catched
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
@@ -209,6 +229,10 @@ type Worker struct {
|
||||
// одной задачи (см. failNotified).
|
||||
const failNotifyDebounce = time.Hour
|
||||
|
||||
// SetNamer подключает вывод отображаемого имени для шага добавления catched
|
||||
// (до запуска Run). nil → имя не выводим, добавляем без rename.
|
||||
func (w *Worker) SetNamer(n Namer) { w.namer = n }
|
||||
|
||||
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||
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 {
|
||||
w.log.Warn("poll failed", "error", err)
|
||||
}
|
||||
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
|
||||
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
|
||||
w.processCatched(ctx)
|
||||
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
|
||||
if w.recognizer != nil {
|
||||
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 и двигает их.
|
||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||
@@ -571,12 +705,21 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive && d.SourceType == store.SourceMagnet {
|
||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
}); err != nil {
|
||||
if !alive {
|
||||
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
||||
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
||||
// не зовём — имя уже могло быть выведено при первом добавлении).
|
||||
_, addReq, prepErr := w.sourceAddParts(ctx, *d)
|
||||
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.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
type fakeStore struct {
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
@@ -99,7 +100,7 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*
|
||||
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 {
|
||||
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)})
|
||||
}
|
||||
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.Infohashes = cp.Infohashes
|
||||
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 {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
@@ -163,6 +177,20 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.Stat
|
||||
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 {
|
||||
d, ok := f.downloads[id]
|
||||
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
Reference in New Issue
Block a user