Compare commits

..
17 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление display_name после распознавания + гард пустого входа
Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:51:04 +03:00
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.

- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
  provider_hint) на границе разбора, до валидации: strip control/zero-width,
  collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
  кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
  безгодовой второй проход сверки как fallback при известном годе и промахе
  первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
  review. В fallback требуем известный год кандидата (год-unknown → review,
  не авто); гейт год ±1 и инвариант авто-матча не двигаются.

Спеки recognition/metadata-match обновлены, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:45:19 +03:00
avandClaude Opus 4.8 7d8a455e47 Логирование: классификация доменных ошибок (500→409/400) + конвенции
Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.

Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
  (refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
  штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».

Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
  logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
  стадия); правило про *url.Error/секреты в URL; канон категории
  state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
  персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
  и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
  запрещены; аудит подтвердил, что секреты туда не текут).

Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.

Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.

Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:57:12 +03:00
avandClaude Opus 4.8 864c44aebd Беклог: классификация доменных ошибок (500→409/400) и конвенции логирования
Отложенные Tier B/C по итогам ревью Fable (продолжение f8fb4fa): sentinel-
обёртки ErrConflict/валидации, classifyErr для layout.ErrCollision, единая
категория переходов, правила уровней и *url.Error/секретов в docs/conventions,
решение по сырому err.Error() в reasons/error_msg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:32:09 +03:00
avandClaude Opus 4.8 f8fb4fabb3 Логирование: доменная граница ошибок + защита секретов в логах
Приём торрента через Telegram молча падал без записи в логах. Разобрали
цепочку и починили логирование/обработку ошибок по конвенции logging.md
(логирует граница домена один раз, транспорты — нет).

Доменная граница логирует исход:
- ingest.Ingest: сбой БД → ERROR, невалидный источник → DEBUG;
- команды воркера (Apply/Cancel/Retry/Refine/…) — единый чокпоинт logCmd
  (ERROR для инфраструктурного сбоя; DEBUG для conflict/not-ready/not-found),
  закрывает и Telegram-, и HTTP-путь; дублирующие ERROR-логи в tgbot сняты;
- внутренний логгер tgbotapi заведён в slog: сбои long-poll getUpdates
  больше не уходят в stdlib log мимо структурированных логов;
- тихое закрытие канала обновлений бота → ERROR.

Защита секретов (инвариант «секреты не в логи»):
- общий logging.SanitizeErr убирает URL из *url.Error;
- закрыты утечки токена бота (getMe на старте, getFile, Send/Request)
  и api_key TMDB (query-параметр, попадавший в *url.Error на ERROR);
- покрыто тестом internal/logging/sanitize_test.go.

Ревью двумя сабагентами (fable): инфраструктурные и доменные ошибки.
Отложено (не в scope этого коммита): обёртка ErrConflict в
Cancel/Defer/Retry и классификация 500→409/400, обновление docs/conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:29:38 +03:00
avandClaude Opus 4.8 5c3ef79496 Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:50:49 +03:00
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:17:51 +03:00
avandClaude Opus 4.8 e68f96df9e Тулинг: openspec 1.4.1 → 1.5.0, регенерация инструкций opsx
Обновлён npm-пакет @fission-ai/openspec до 1.5.0; `openspec update`
перегенерировал команды opsx:* и скиллы openspec-* под новую версию
(добавлены пояснения про --store для multi-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:39:04 +03:00
avandClaude Opus 4.8 93a1ba8e7e Тулинг: скилл task-pipeline + два ревьювера качества
Скилл .claude/skills/task-pipeline оркеструет задачу по SDD от беклога до
коммита (opsx explore→propose→ревью спек→apply→ревью кода→archive→чистка
беклога), автономно, с выходом на пользователя только на развилках.

Кастомные ревьюверы .claude/agents: jellybit-review-specs (оптика спек) и
jellybit-review-code (архитектура/инварианты/конвенции/стиль). Подключены как
чекпоинты скилла: на тривиальной задаче — один review-code, на нетривиальной —
оба параллельно.

Частично закрывает беклог-задачу agenty-revyuvery-kachestva: остался ревьювер
наименований (ждёт словарь единого языка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:37:00 +03:00
avandClaude Opus 4.8 2bd2a97bfc Беклог: длинное имя .torrent-файла ломает верстку add-row
Выбор .torrent с длинным именем распирает лейбл файл-пикера (нет ограничения
ширины у .btn-file), поле source сжимается, «Добавить» уезжает. Косметика:
ellipsis на лейбле; фикс-хуки на CSS/шаблон в теле задачи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:36:14 +03:00
avandClaude Opus 4.8 2a5a65f2d5 Приём: пропажа источника у активной загрузки → failed(source_gone) (MAJOR-3)
Раздача активной (downloading) загрузки, исчезнувшая из qBittorrent (удалил
пользователь/другой клиент), делала задачу вечным зомби: поллинг промахивался
по torrentFor, писал Warn и continue каждый тик — состояние не менялось,
уведомления и телеметрии не было, checkTimeouts без торрента не срабатывал.
Пропажей источника у downloading не владел никто (сверка рассинхрона покрывает
только done/target_missing/orphaned, восстановление — failed/stuck).

Активный цикл Poll теперь применяет тот же дебаунс пропажи источника, что и
сверка рассинхрона (source_miss_count / source_missing_threshold): после порога
подряд идущих промахов задача уходит downloading → failed с distinct error_code
source_gone и уведомлением. До порога транзиентная недоступность qBit
(рестарт демона) задачу не роняет. source_gone восстановлению сверкой не
подлежит (удаление намеренно), но штатно retriable — Retry заново отдаёт
сохранённый источник; Retry сбрасывает source_miss_count, чтобы вернувшаяся
задача получила полное грейс-окно, а не упала снова на ближайшем тике.

Ребро downloading → failed уже было в графе, миграций/полей БД нет. Спека
download-tracking дополнена требованием, диаграмма workflow.md — ребром.
Change downloading-source-gone заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:05:04 +03:00
avandClaude Opus 4.8 bfd469bd43 Беклог: закрыты F1, F6, MAJOR-1/2, MAJOR-4, MINOR-7
Реализованы и прошли ревью параллельной волной (worktree):
- F1 (гард дедуп-дозаписи) + F6 (апгрейд catched-magnet→torrent)
- MAJOR-1/2 (сброс базиса ретрая + простой от last_activity) + клэмп
  last_activity из будущего
- MAJOR-4 (sweep linking + persist→review) + MINOR-7 (transitionErr)

Суть переехала в openspec/specs (ingest, state-reconciliation,
file-layout) и в код. В F2 добавлен указатель на смежное окно
F6↔воркер, найденное этим ревью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:37:44 +03:00
avandClaude Opus 4.8 78c61605fd Retry/stall: игнорировать last_activity из будущего (клэмп)
Наблюдение из ревью кластера B: если qBittorrent отдаёт last_activity
впереди now (перекос часов или sentinel «никогда не был активен»),
stallDuration уходил в минус и реально застрявший торрент никогда не
помечался stuck. Теперь значение из будущего трактуется как непригодное
и простой считается от базиса добавления (addedBasis), как при
отсутствующем last_activity. Поведение спеки не меняется — оборонительная
деталь реализации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:35:33 +03:00
avandClaude Opus 4.8 4cc4de4269 OpenSpec: архивация трёх параллельных changes + синк спек
Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master):
- ingest-dedup-integrity (F1, F6) → спека ingest
- retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation
- linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout
  и state-reconciliation

Дельты влиты в openspec/specs, changes перенесены в
openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:21:22 +03:00
avandClaude Opus 4.8 9bab7dc402 Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками.

MINOR-7: transition глотал ошибку записи состояния — на путях Apply и
авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim
перехода в linking, а финальный linking→done отклонялся графом (задача застревала
со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и
finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition
(void) сохранена для fire-and-forget переходов — соседние функции воркера не
тронуты.

MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет
задачу в linking голым return — уводим в review (код persist), повтор Apply
идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после
краха linking-задачи в review (код interrupted) на тике и старте; любая linking
под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального
состояния есть владелец».

Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim
не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking.

OpenSpec-change linking-transition-robustness (дельты file-layout,
state-reconciliation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
avandClaude Opus 4.8 8261d5b55d Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)
Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт.
NIT-12: retry перестаёт перецепляться к сломанному живому торренту
(error/missingFiles) — повторно отдаёт источник (перецепка к нему
бессмысленна: reconcile тут же вернул бы в failed).

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
avandClaude Opus 4.8 4475fbd548 Приём: гард дедуп-дозаписи хешей (F1) и апгрейд catched-magnet до torrent (F6)
Два дефекта дедуп-веток приёма (ревью Fable 2026-07-08), оба про инвариант
«≤1 активная загрузка на infohash» и сохранность источника.

F1: дедуп-ветка CreateDownloadIfNoActive дописывала все хеши входящего
источника в найденную активную задачу без пер-хеш гарда владения (в отличие
от AddInfohashes). Гибрид {v1,v2}, дедупнувшись на задачу B (владелец v2),
крал v1 у активной A → две активные владели v1. Теперь дозапись под тем же
гардом: хеш, которым владеет другая активная задача, не дописывается.

F6: при дедупе .torrent-байт на пойманную magnet-задачу (catched) байты
выбрасывались, source_type оставался magnet → worker добавлял по magnet-URL →
вечный metaDL → failed (magnet закрытого трекера без DHT метаданные не
докачает). Новый guarded-метод UpgradeCatchedMagnetToTorrent атомарно
сохраняет байты и меняет source_type magnet→torrent, но только пока задача в
catched (worker источник ещё не отдал). Ingest зовёт апгрейд на обоих
дедуп-путях. Это целевое исключение из правила спеки «при дедупе байты не
сохраняем» — оформлено MODIFIED-дельтой ingest.

Схема БД не меняется (download_torrent и source_type уже есть).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00
133 changed files with 7418 additions and 448 deletions
+71
View File
@@ -0,0 +1,71 @@
---
name: jellybit-review-code
description: Ревьювер кода для jellybit (Go) — оптика архитектуры, инвариантов безопасности данных, конвенций (ошибки, логирование, конфиг, время/UTC, ULID, миграции, htmx), стиля и дублирования. Запускается как чекпоинт перед archive/коммитом: на нетривиальной задаче — в паре с jellybit-review-specs, на тривиальной — один (тогда в задании его просят бегло сверить и соответствие спекам). Работает только на чтение, код не меняет.
tools: Read, Grep, Glob, Bash
color: yellow
---
Ты — ревьювер кода проекта **jellybit** (Go, один статический бинарь
`CGO_ENABLED=0`; связующий сервис qBittorrent ↔ Jellyfin, SQLite через
`modernc.org/sqlite`). Твоя оптика — **архитектура, инварианты, конвенции, стиль
и дублирование**. Находки пиши по-русски, идентификаторы и пути — в оригинале.
Читай реальный код перед выводом, ничего не выдумывай.
## Контекст, который надо прочитать
`CLAUDE.md` (принципы, инварианты, конвенции кода), `docs/specs/architecture.md`,
относящиеся файлы `docs/conventions/*` (errors, logging, config, database,
web-ui), диф разбираемого change (`git diff` / `git status` /
`git log --oneline`).
## Что проверяешь
- **Архитектурные границы.** Единое ядро / тонкие транспорты: вся логика приёма
в use-case `Ingest`; HTTP API, веб-UI и Telegram — лишь обёртки, без бизнес-
логики в транспортах. Размещение по пакетам `internal/<компонент>` согласно
architecture.md. Минимум компонентов, без лишних сущностей.
- **Инварианты безопасности данных.** Источник неприкосновенен: только `mkdir` /
`link(2)` / `unlink` своих ссылок, никогда не трогаем файлы под
`paths.downloads`. Целевой путь санитизируется и строго под
`paths.movies`/`series` (защита от traversal), существующее не
перезаписываем. Выход LLM недоверенный — безопасность на валидации пути.
Секреты (пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки) не попадают
в логи.
- **Ошибки.** Stdlib, обёртка с контекстом (`fmt.Errorf("...: %w", err)`),
проверка через `errors.Is`/`errors.As`, трансляция на внешней границе.
- **Логирование.** Только `slog`, без `fmt.Println`; корректные уровни,
обязательные поля, ничего секретного.
- **Конфиг.** Только TOML, секреты из файла (не env), валидация на старте.
- **Время.** UTC, RFC 3339 с суффиксом `Z`, генерирует только приложение
(`store.Now()`); таймзона отображения — конфиг `[general].timezone`.
- **Идентификаторы.** TEXT ULID (lowercase) через `internal/ident`, без числовых
AUTOINCREMENT; внешние id валидируются `ident.Parse` на границе.
- **Миграции.** goose в `internal/store/migrations`; при изменении структуры
(таблица/столбец/индекс/связь) в том же change обновлена ER-схема
`docs/specs/database.md`.
- **Веб-UI (htmx).** Единый партиал = страница = фрагмент, ветвление по `isHTMX`,
деградация без JS, ошибка на htmx-пути = 200 + фрагмент, самозавершающийся
поллинг.
- **Стиль и дублирование.** Код читается как окружающий (нейминг, плотность
комментариев, идиомы). Ищи копипасту и упущенные возможности переиспользования,
но без золочения — правки должны быть right-size под задачу.
Если в задании просят (тривиальная задача, ты единственный ревьювер) — добавь
**беглую** сверку с дельта-спеками и tasks.md change: реализовано ли заявленное,
нет ли забытых задач. Глубокую спек-проверку на нетривиальных делает
`jellybit-review-specs`.
## Формат вывода
Находки по критичности, каждая — с файлом/строкой и кратким «почему»:
- **Блокеры** — нарушенные инварианты, сломанная архитектура, утечка секретов,
баги обработки ошибок/данных.
- **Важное** — отступления от конвенций, дублирование, слабые места.
- **Мелочь-инлайн** — то, что оркестратор поправит сам.
- **Развилки-для-автора** — где нужно решение человека (крупная переработка,
компромисс). Формулируй как вопрос с вариантами.
## Ограничения
Только чтение и анализ. Не редактируй код, не запускай сборку/тесты с
сайд-эффектами, не коммить. Результат — текст находок для оркестратора.
+66
View File
@@ -0,0 +1,66 @@
---
name: jellybit-review-specs
description: Ревьювер спек и требований для jellybit (Spec Driven Development на OpenSpec). Оптика — соответствие реализации/дизайна дельта-спекам и tasks: покрытие Requirements и сценариев GIVEN/WHEN/THEN, целостность и непротиворечивость дизайна, границы scope, отражение инвариантов безопасности данных в спеке. Используется на двух чекпоинтах ревью-процесса: ревью дизайна/спек ДО кода и сверка кода со спеками ПОСЛЕ apply. Работает только на чтение, код не меняет.
tools: Read, Grep, Glob, Bash
color: cyan
---
Ты — ревьювер спецификаций проекта **jellybit** (Go, один статический бинарь;
связующий сервис qBittorrent ↔ Jellyfin). Разработка идёт по Spec Driven
Development через OpenSpec: сперва спека — потом код. Твоя оптика — **спеки и
требования**, а не стиль кода. Находки пиши по-русски, идентификаторы, пути и
ключевые слова спек (`SHALL`, `GIVEN/WHEN/THEN`) — в оригинале. Читай реальные
файлы перед выводом, ничего не выдумывай.
## Контекст, который надо прочитать
Всегда сперва подними: `CLAUDE.md` (раздел «Инварианты» и «Spec Driven
Development»), `openspec/changes/<id>/` разбираемого change (proposal.md,
design.md, дельта-спеки с `ADDED/MODIFIED/REMOVED Requirements`, tasks.md),
затронутые `openspec/specs/*/spec.md`, `docs/specs/architecture.md`. Если тема
ещё живёт в `docs/specs/` (не перенесена в OpenSpec) — источник истины там.
## Два режима (что ревьюишь — скажут в задании)
1. **Дизайн/спеки ДО кода.** Проверяешь сам change как артефакт: полнота
покрытия постановки; сценарии `GIVEN/WHEN/THEN` без дыр, противоречий и
недостижимых веток; scope не раздут и не урезан молча; каждый
`### Requirement` содержит литерал `SHALL` или `MUST`; структурные заголовки
английские; согласованность с текущими спеками и capability-нарезкой; в спеке
отражены задетые инварианты безопасности данных (источник неприкосновенен,
санитизация целевого пути и защита от traversal, недоверенный выход LLM,
секреты не в логах). Отметь, если `openspec validate --strict <id>` очевидно
упадёт.
2. **Код против спек ПОСЛЕ apply.** Сверяешь реализацию с дельта-спеками и
tasks.md: все ли Requirements и сценарии реально реализованы; нет ли
отклонений от согласованного дизайна; покрыты ли ключевые сценарии тестами;
не осталось ли незакрытых или потерянных задач в tasks.md. Диф бери через
`git diff` / `git status` / `git log --oneline`.
## Метод
1. Выпиши нумерованный чек-лист Requirements и сценариев из дельта-спек.
2. Сопоставь каждый пункт с дизайном (режим 1) или с кодом/тестами (режим 2);
помечай: Покрыто / Частично / Не покрыто / Неоднозначно.
3. Для каждого конкретного утверждения открой реальный источник и подтверди —
не заявляй поведение, которого не прочитал.
4. Отдельно проверь инварианты безопасности данных: где спека/код трогают
раскладку файлов, пути, источник (`paths.downloads`) — убедись, что заявлены
и соблюдены гарантии (только свои ссылки, строго под `paths.movies`/`series`,
существующее не перезаписываем).
## Формат вывода
Верни находки, сгруппированные по критичности:
- **Блокеры** — дыры покрытия, нарушенные инварианты, противоречия, невыполнимая
спека. Каждый — с указанием файла/пункта и кратким «почему».
- **Важное** — неоднозначности, слабое тестовое покрытие сценария, риск scope.
- **Мелочь-инлайн** — то, что оркестратор поправит сам без обсуждения.
- **Развилки-для-автора** — где нужно решение человека (компромисс, смена scope,
трактовка требования). Формулируй как вопрос с вариантами.
## Ограничения
Только чтение и анализ. Не редактируй код и спеки, не запускай ничего с
сайд-эффектами, не архивируй change. Твой результат — текст находок для
оркестратора, а не правки.
+2 -2
View File
@@ -7,6 +7,8 @@ tags: [workflow, artifacts, experimental]
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -46,8 +48,6 @@ Implement tasks from an OpenSpec change.
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
+2 -2
View File
@@ -7,6 +7,8 @@ tags: [workflow, archive, experimental]
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -29,8 +31,6 @@ Archive a completed change in the experimental workflow.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
+2
View File
@@ -11,6 +11,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
+2
View File
@@ -16,6 +16,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
+2 -2
View File
@@ -9,6 +9,8 @@ Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -28,8 +30,6 @@ This is an **agent-driven** operation - you will read delta specs and directly e
openspec status --change "<name>" --json
```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
@@ -6,11 +6,13 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
generatedBy: "1.5.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -50,8 +52,6 @@ Implement tasks from an OpenSpec change.
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
@@ -6,11 +6,13 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
generatedBy: "1.5.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -33,8 +35,6 @@ Archive a completed change in the experimental workflow.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
+3 -1
View File
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
generatedBy: "1.5.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
@@ -15,6 +15,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
+3 -1
View File
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
generatedBy: "1.5.0"
---
Propose a new change - create the change and generate all artifacts in one step.
@@ -20,6 +20,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
+3 -3
View File
@@ -6,13 +6,15 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
generatedBy: "1.5.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@@ -32,8 +34,6 @@ This is an **agent-driven** operation - you will read delta specs and directly e
openspec status --change "<name>" --json
```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
+136
View File
@@ -0,0 +1,136 @@
---
name: task-pipeline
description: Автономно проводит задачу jellybit через полный цикл SDD — от выбора в беклоге до коммита (opsx explore→propose→ревью спек→apply→ревью кода→archive→чистка беклога). Использовать, когда пользователь просит взять/сделать задачу из беклога или довести идею до реализации.
---
# Пайплайн задачи (jellybit)
Оркестратор одной задачи по Spec Driven Development: проводит её от беклога до
коммита максимально автономно, привлекая пользователя **только на реальных
развилках** (компромиссы, изменение scope, угроза инвариантам). Механику не
согласовываем — делаем.
Перед стартом прочитай `CLAUDE.md`, а также `README.md`, `BRIEF.md`,
`docs/specs/architecture.md`, если ещё не в контексте. Это тонкая обёртка над
каноническими скиллами `opsx:explore` / `opsx:propose` / `opsx:apply` /
`opsx:archive` — вызывай их через Skill, не переизобретай их шаги.
## Принцип автономности
Зови пользователя (через **AskUserQuestion**) только когда решение реально его:
- **Выбор задачи**, если он не задан явно.
- **Развилки грумминга** на explore: несколько равнозначных направлений,
спорный scope, продуктовый компромисс.
- **Замечания ревью спек**, требующие выбора: смена подхода, урезание/расширение
scope, риск инварианту безопасности данных.
- Всё остальное — механика: делаем без спроса. Мелкие замечания ревью чиним
инлайн, не логируем (память `review-before-backlog-cleanup`).
Стиль правок — заточка под проект и конвенции, right-size, без золочения
(память `convention-design-approach`).
## Шаги
### 1. Выбрать / прочитать задачу
- Если задача задана (slug, файл в `docs/backlog/`, ссылка Tududi или описание) —
прочитай её файл и связанные спеки/ADR/черновики.
- Если не задана — покажи топ-кандидатов из `docs/backlog/README.md` (высокий
приоритет, не `[идея]`) через **AskUserQuestion** и дай выбрать.
- Задача с префиксом `[идея]` (ещё без решения «делаем») — сперва обязательно
через explore (шаг 2), там она либо становится задачей, либо остаётся идеей.
Оцени тривиальность (влияет на шаг 4):
- **Тривиальная** — локальная правка без изменения поведения/спек/схемы БД,
очевидное решение. Explore и ревью спек пропускаем.
- **Нетривиальная** — новое/изменённое поведение, дизайн-развилки, затрагивает
инварианты, схему БД или несколько capability. Полный цикл.
### 2. (Опц.) Груммить идею — `opsx:explore`
Только для `[идея]`-задач или когда постановка мутная. Вызови Skill
`opsx:explore`. Развилки грумминга — на пользователя (AskUserQuestion). Выход:
ясная постановка, готовая к propose. **В explore не пишем код.**
### 3. Завести change — `opsx:propose`
Вызови Skill `opsx:propose`. Получаем `proposal.md`, дизайн (для нетривиальных),
дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`), `tasks.md`. Каждое
`### Requirement` содержит `SHALL`/`MUST`; структурные заголовки английские,
сценарии `GIVEN/WHEN/THEN`. Прогони `openspec validate --strict <id>`.
### 4. (Нетривиальная) Ревью спек — сабагент, ДО кода
Первый чекпоинт ревью-процесса из CLAUDE.md. Запусти **один** сабагент
`jellybit-review-specs` (Agent tool, `subagent_type`) в режиме «дизайн/спеки ДО
кода». Charter самодостаточен — дай ссылку на change `<id>`. Агент проверит
полноту покрытия, сценарии `GIVEN/WHEN/THEN`, scope, инварианты безопасности
данных, согласованность со спеками и capability-нарезкой, наличие `SHALL`/`MUST`.
### 5. Отработать замечания ревью спек
- Мелочь и явные улучшения — правь сам в спеках/дизайне.
- Развилки (компромисс, scope, инвариант) — на пользователя (AskUserQuestion).
- После правок перепрогони `openspec validate --strict <id>`.
### 6. Написать код — `opsx:apply`
Вызови Skill `opsx:apply` для реализации `tasks.md`. Код по конвенциям
`docs/conventions/*`: ошибки stdlib с `%w`/`errors.Is`, логи только `slog` без
секретов, время в UTC через `store.Now()`, ULID через `internal/ident`, миграции
goose + синк ER-схемы `docs/specs/database.md`, htmx по web-ui-конвенции.
Прогони `task test` и `task lint` (или `task build`), добейся зелёного.
### 7. Ревью кода — сабагент(ы)
Второй чекпоинт. Ревьюеры — кастомные агенты из `.claude/agents/` (запускай их
через Agent tool с `subagent_type`). Число зависит от тривиальности:
- **Тривиальная задача — один сабагент** `jellybit-review-code`. В промпте
добавь просьбу дополнительно **бегло сверить соответствие дельта-спекам и
tasks.md** (он единственный, покрывает и спеки, и конвенции).
- **Нетривиальная — два параллельных сабагента одним сообщением**, чтобы шли
конкурентно: `jellybit-review-specs` (оптика спек) и `jellybit-review-code`
(оптика архитектуры/конвенций/стиля).
Charter'ы агентов самодостаточны — детальный промпт писать не нужно, дай ссылку
на change (`<id>`) и diff/список файлов (`git diff`).
Отработай так же, как шаг 5: мелочь чини инлайн, развилки — на пользователя.
После правок — снова `task test`/`task lint`.
### 8. Архивировать — `opsx:archive`
Вызови Skill `opsx:archive`: change уезжает в `openspec/changes/archive/`,
дельты вливаются в `openspec/specs/`.
### 9. Закрыть беклог и синк доков
Ревью выполненного — **до** чистки (память `review-before-backlog-cleanup`).
Затем:
- Удали файл задачи `docs/backlog/<slug>.md` и строку в `docs/backlog/README.md`
(реализованное не держим в беклоге — CLAUDE.md).
- Суть переехавшего решения — в `docs/specs`/`docs/adr`, если ещё не там.
- Если менялась структура БД — убедись, что ER-схема `docs/specs/database.md`
обновлена в этом же change.
### 10. Коммит
Коммить **прямо в master**, без feature-веток (память
`commit-directly-to-master`). Сообщение — по-русски, в стиле недавних коммитов
(`git log --oneline -8`): область + суть. Одна задача — один осмысленный коммит
(или несколько по фазам, если так шёл apply).
Готово — доложи пользователю кратко: что сделано, какие развилки решались, ссылки
на архивный change и спеки.
## Тонкости
- Не пропускай `openspec validate --strict` перед архивацией.
- Тривиальная задача: шаги 2 и 4 пропускаются; ревью кода (шаг 7) оставляем, но
одним сабагентом на всё. Два параллельных ревьювера — только на нетривиальных.
- Если сабагент-ревьюер сам предлагает крупную переработку — это развилка, не
правь молча, вынеси пользователю.
- Держи пользователя в цикле короткими репликами на переходах фаз, но не проси
подтверждать механику.
+9 -1
View File
@@ -182,9 +182,17 @@ func runServe(args []string) error {
if perr != nil {
return perr
}
// Внутренние логи tgbotapi (сбои long-poll getUpdates и пр.) — в наш slog
// вместо stdlib log мимо структурированных логов; токен вырезается.
if lerr := tgbot.SetLibraryLogger(logger, cfg.Telegram.Token); lerr != nil {
logger.Warn("telegram library logger not set", "error", lerr)
}
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
if terr != nil {
logger.Error("telegram bot disabled, cannot connect", "error", terr)
// NewBotAPIWithClient дёргает getMe: при недоступном Telegram/прокси
// terr — *url.Error с URL …/bot<TOKEN>/getMe; санитизируем, чтобы
// токен не утёк в лог.
logger.Error("telegram bot disabled, cannot connect", "error", logging.SanitizeErr(terr))
} else {
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
+3 -9
View File
@@ -16,19 +16,14 @@ 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
- [Агенты-ревьюверы качества (наименования, архитектура, конвенции, стиль)](agenty-revyuvery-kachestva.md) — Ядро (specs+code ревьюверы) сделано и вшито в task-pipeline; остался ревьювер наименований (ждёт словарь единого языка)
- [[идея] Сила совпадения кандидата и пересмотр распознавания/матчинга](sila-sovpadeniya-kandidata.md) — ИДЕЯ (сперва проработать)
- [История переходов загрузки](istoriya-perehodov-zagruzki.md) — Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто инициировал…
- [Привязка уведомлений к источнику в ботах (мульти-бот)](uvedomleniya-multi-bot.md) — Уведомления и запросы подтверждения должен получать тот, кто прислал загрузку: автор…
@@ -40,22 +35,21 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Обучение на правках человека (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) — просто и работает…
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
- [Версии/качество одного тайтла (репаки, апгрейд 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-гард)
- [Форма приёма: длинное имя .torrent-файла ломает верстку add-row](torrent-fayl-dlinnoe-imya.md) — длинное имя файла распирает лейбл файл-пикера, поле source сжимается — нужен ellipsis
- [Раздачи-копии диска (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 одним и тем же…
+18 -1
View File
@@ -4,4 +4,21 @@
Набор узких сабагентов-ревьюверов поверх ревью-процесса из CLAUDE.md, каждый со своей оптикой: соответствие наименований словарю единого языка, соблюдение архитектурных границ (единое ядро/тонкие транспорты, инварианты безопасности данных), конвенций (ошибки, логирование, конфиг, TZ), стиля кода и поиск дублирования. Запускаются как чекпоинт перед archive/коммитом. Развивает ревью-процесс OpenSpec в сторону воспроизводимых автопроверок, не заменяя человеческое ревью.
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка».
## Сделано (2026-07-10)
- Заведены два кастомных ревьювера в `.claude/agents/`: `jellybit-review-specs`
(оптика спек/требований) и `jellybit-review-code` (архитектура, инварианты,
конвенции, стиль, дублирование).
- Оба подключены как чекпоинт в скилл `.claude/skills/task-pipeline` (ревью спек
ДО кода + ревью кода перед archive; на тривиальной задаче — один
`jellybit-review-code`, на нетривиальной — оба параллельно).
## Осталось
- **Ревьювер наименований** (соответствие словарю единого языка) — отдельной
оптикой пока не выделен: зависит от задачи «Словарь единого языка
(ubiquitous language)», без глоссария проверять не по чему. Завести после неё.
- По опыту эксплуатации — решить, дробить ли `jellybit-review-code` на более
узкие оптики (архитектура / конвенции / стиль+дублирование) или оставить одним.
Связано: CLAUDE.md (ревью-процесс, конвенции), docs/conventions, «Словарь единого языка», скилл `task-pipeline`.
@@ -0,0 +1,54 @@
# Шум ERROR фоновых циклов при недоступной зависимости
**Приоритет:** низкий · **Теги:** review-fable, logging, reliability
Остаток от задачи «классификация доменных ошибок + конвенции логирования»
(основное реализовано, см. ниже). Здесь — два смежных пункта про уровень
повторяющихся сбоев фоновых циклов, каждый требует небольшого решения, а не
только правки.
## Что уже сделано (не переоткрывать)
Коммит `f8fb4fa` (Tier A) + коммит этой задачи закрыли:
- **Классификация доменных ошибок:** sentinel `worker.ErrInvalidInput`→400;
обёртки `ErrConflict` в Cancel/Retry/Defer/Undo; `layout.ErrCollision`→409 в
`classifyErr` и ветка в tgbot; `logCmd` относит новые классы в DEBUG.
- **Конвенции:** `logging.md` — команды воркера = доменная граница, таблица
уровней доменных отказов (граница команды vs асинхронная стадия), правило про
`*url.Error`/секреты в URL, канон категории `state transition` (унифицированы
cancel/retry/relink/recovery). `errors.md` — таблица маппинга ошибка→статус,
развилка «транзиентный ответ vs персистентная диагностика» решена как (а):
`error_msg`/`reasons` — операторская поверхность владельца (сырой текст ок,
секреты запрещены; аудит показал, что секреты туда не текут).
- **Мелочи:** reason-коды const-блок; лог-поля `id``download_id`; preview
WARN; комментарий у `parseIgnored`.
## Остаток
### ERROR-шторм при недоступном qBittorrent
Клиент `qbt` логирует `ext.*` `Failure`**ERROR** на каждом тике поллинга
(`torrents/info`, `internal/qbt/qbt.go`), пока qBittorrent недоступен (рестарт
демона, сеть). Домен уже пишет `poll failed` = WARN (по новой конвенции), но
транспортная `ext.*`-запись остаётся ERROR по правилу ext-конвенции («сервис
недоступен → ERROR»). При частом поллинге это шумит.
Развилка (решить до правки):
- (а) Ввести у `logging.ExtCall` вариант с пониженным уровнем для рутинно-частых
вызовов (симметрично `SuccessDebug`) — поллинг-вызовы (`torrents/info`) на
транзиентном сбое пишут WARN, не ERROR;
- (б) Дедуп/circuit-breaker: первый ERROR, дальше тишина до восстановления;
- (в) Оставить как есть, признав `ext.*` ERROR легитимным сигналом «зависимость
лежит» (тогда шум гасить уровнем сбора, а не кодом).
### Эскалация устойчивого сбоя тика
Сейчас транзиентный сбой тика = WARN всегда. Договорённость на будущее
(`logging.md`): устойчивый сбой N тиков подряд эскалировать в ERROR (реальная
деградация, а не разовый промах). Не реализовано — нужен счётчик подряд-сбоев по
циклу и порог в конфиге.
Вердикт: мелкая надёжностная полировка, не блокер. Делать вместе (обе про
уровень сбоев фоновых циклов) или отдельной строкой.
@@ -1,13 +0,0 @@
# Гейт дозаписи хешей в 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 уже открыта.
Вердикт: простой фикс (поведение уже обещано спекой).
@@ -10,4 +10,10 @@
Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry).
Смежное (ревью 2026-07-08, кластер A): апгрейд F6 (`UpgradeCatchedMagnetToTorrent`)
оставил узкое окно — `processCatched` читает снимок `source_type` вне `w.mu`, поэтому
при точном оверлапе тика воркера с апгрейдом воркер добавит magnet из устаревшего
снимка, хотя БД уже `torrent`. Тот же фикс закрывает и это: перечитать источник под
`w.mu` (или проверить присутствие хешей в qBit) перед Add.
Вердикт: change (малая спека-дельта download-tracking + код).
@@ -1,11 +0,0 @@
# .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 (противоречит текущему предложению спеки, нужна дельта).
@@ -1,13 +0,0 @@
# Retry/stall семантика: сброс базиса таймаута + простой от начала, а не от возраста торрента (MAJOR-1, MAJOR-2)
**Приоритет:** высокий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Два связанных бага. worker.go:677-734 (Retry), :514-525 (checkTimeouts), :578-592 (torrentAge).
MAJOR-1: Retry с живым торрентом (alive=true) не переиздаёт Add, только ActivateIfNoOtherActive→downloading; базис age=nowadded_on НЕ сбрасывается → следующий тик: stalledDL && age>StuckAfter → снова stuck (~5с). Спека state-reconciliation «Ручной повтор» требует: базис SHALL сбрасываться. Комментарий worker.go:690-691 верен лишь для re-Add ветки. Тест TestRetryReattaches не гоняет следующий тик.
MAJOR-2: stuck_after меряет ВОЗРАСТ торрента (от added_on), а не длительность простоя. Торрент, качавшийся 5ч, при мгновенном stalledDL на 1 тик → stuck с сообщением «stalled for 5h» (ложь) + EventFailed. Проход через stalledDL между пирами — норма → флап stuck↔downloading + до-часовые ложные уведомления. Спека сама противоречива («stalledDL дольше stuck_after» vs «возраст от added_on»).
Фикс: колонка retried_at и/или stalled_since (или qBit last_activity); базис = max(added_on, retried_at); простой мерить от stalled_since. Схема + миграция + сверка спеки. Покрывает также NIT-10 (фолбек added_on→created_at) и NIT-12 (retry на qbit_error мгновенно откатывается).
Вердикт: полноценный change (схема + спека). Бьёт по повседневным сценариям — retry выглядит сломанным, длинные загрузки спонтанно флапают в stuck.
@@ -1,11 +0,0 @@
# Восстановление 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. Классический «застрявшее состояние, которое никто не двигает».
@@ -1,13 +0,0 @@
# 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 спека-сценарий).
@@ -1,11 +0,0 @@
# 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.
Вердикт: простой фикс.
+30
View File
@@ -0,0 +1,30 @@
# Форма приёма: длинное имя .torrent-файла ломает верстку add-row
**Приоритет:** низкий · **Теги:** web-ui, ingest
При выборе `.torrent`-файла с длинным именем (типичный релиз с трекера, напр.
«Бухта вдов Widow's Bay Сезон 1 Серии 1-10 из 10 (Хиро Мурай) [2026, США, …,
WEB-DL 1080p] 7 x MVO (HDr [rutracker-6869937].torrent») кнопка-лейбл файл-пикера
раздувается на всю ширину и ломает раскладку строки добавления: поле `source`
сжимается/прячется, «Добавить» уезжает.
Причина: onchange пишет полное `files[0].name` в `[data-file-label]`
(`web/templates/index.html:26-30`), а у `.btn-file` нет ограничения ширины
(`web/static/css/jellybit.css:194` — только цвет). В `.add-row`
(`display:flex`, `.input{flex:1}`) лейбл распирается по контенту и съедает строку.
Фикс (косметика, без домена):
- CSS `.btn-file`: `max-width` + `overflow:hidden; text-overflow:ellipsis;
white-space:nowrap` — имя усекается многоточием, а не растягивает строку.
- Опц.: показывать базовое имя без `.torrent` и класть полное в `title`
(тултип). Полное имя всё равно уходит на сабмит через сам file-input
(`header.Filename`), правка чисто визуальная.
Смежное (проверить заодно, не обязательно тем же фиксом): на приёме `source_ref`
выводится из имени раздачи/файла и может быть очень длинным. В списке/карточке он
усечён (`shorten(oneLine(d.SourceRef), 120)` — `internal/httpapi/download.go:67`),
так что отображение ок; убедиться лишь, что нет жёсткого лимита колонки в БД,
роняющего сам приём длинного имени.
Связано: `openspec/specs/ingest`, `openspec/specs/web-ui`, пакет `httpapi`,
конвенция [web-ui](../conventions/web-ui.md).
-52
View File
@@ -1,52 +0,0 @@
# Полное удаление загрузки из 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.
@@ -1,13 +0,0 @@
# Проблема второго сезона (сходимость папки сериала)
**Приоритет:** высокий
Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти одинаковую. Проблема не в группировке, а в сходимости папки: имя печатается заново из выхода 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
+38 -7
View File
@@ -68,14 +68,45 @@ jellybit — **приложение, а не библиотека**: внешн
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
не «произошла ошибка» и не сырой текст;
- **маппинг доменной ошибки → статус/сообщение**: `ErrNotFound` → 404
«не найдено», валидация/`ErrNotMagnet` → 400 «некорректный источник»,
конфликт состояния (`ErrConflict` — операция недопустима в текущем
состоянии) → 409 «действие недоступно в текущем состоянии», прочее →
500 «внутренняя ошибка».
- **маппинг доменной ошибки → статус/сообщение** (в jellybit —
`httpapi.classifyErr`, единая точка для REST и веб-UI):
Граница публичная по умолчанию. Истинно приватный для владельца канал —
логи; отдельной «операторской» поверхности с сырыми ошибками не заводим.
| Доменная ошибка | Статус | Сообщение |
|---|---|---|
| `store.ErrNotFound` | 404 | «не найдено» |
| `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа (конфликт/валидация) заводим sentinel’ом и
добавляем сюда — иначе `default` отдаст 500 «внутренняя ошибка» на
нормальный конфликт (и логирующая граница спишет его в `ERROR` вместо
`DEBUG`, см. [logging.md](logging.md)).
### Транзиентный ответ vs персистентная диагностика
У публичной границы две разные поверхности, и правило сырого текста для них
разное:
- **Транзиентный ответ на действие** (тело REST/`?err=`/answer бота по
результату команды) — строго нейтральный: маппинг выше, `err.Error()` наружу
не идёт, полная ошибка — в логах по `download_id`/`request_id`.
- **Персистентная диагностика состояния** — `error_msg` перехода (причина ухода
в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
**операторская поверхность владельца**: сервис однопользовательский в
доверенной LAN (см. [architecture.md](../specs/architecture.md)), эти поля —
диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
- **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
же, как в логах ([logging.md](logging.md), «Безопасность»). Источник
error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
транспорта, несущих URL с секретом);
- это **не** канал для транзиентных отказов команд — те остаются нейтральными
(см. выше).
## panic
+51 -4
View File
@@ -39,6 +39,13 @@ log.Info(fmt.Sprintf("download %s accepted as movie", id))
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
- **Смена состояния загрузки — единая категория `state transition`** с полями
`from`/`to`/`code` (какое именно состояние и по какой причине — это данные,
не текст). Любой переход (в т.ч. `cancel`/`retry`/`relink`) пишет этот
`msg`, чтобы весь жизненный цикл собирался одним фильтром: `jq
'select(.msg=="state transition" and .download_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`layout linked`,
`layout reverted`, `review hint added`), не подменяет запись перехода.
## Уровни
@@ -141,13 +148,44 @@ log.Error(err.Error())
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
контекст накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя** (use-case
`Ingest`, стадии воркера), которая определяет исход операции: полем
`error`, уровень `ERROR`. В Go логирует этот единый чокпоинт, а не каждый
транспорт — так транспорты остаются тонкими.
- Логируем ошибку **один раз — на границе доменного слоя**, которая
определяет исход операции: полем `error`. В Go логирует этот единый
чокпоинт, а не каждый транспорт — так транспорты остаются тонкими. Границы
в jellybit:
- use-case `Ingest` (приём);
- **асинхронные стадии воркера** (поллинг, распознавание, авто-раскладка) —
исход стадии, вызванной таймером/циклом;
- **публичные команды воркера** (`Apply`/`Refine`/`Cancel`/`Retry`/`Undo`/
`Delete`/…), вызываемые транспортами. Исход команды логирует ровно один
чокпоинт (`worker.logCmd`, в `defer` при именованном возврате), а не
HTTP/web/Telegram — они одну и ту же команду зовут из трёх мест.
- Транспорты (HTTP/web/Telegram) переводят возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и **не логируют** её повторно — иначе
один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
доменной ошибки ровно один логирующий; уровень выбирает он. На **границе
команды** (пользователь инициировал действие и ждёт ответа — `worker.logCmd`):
| Класс отказа | Кому | Уровень |
|---|---|---|
| штатный конфликт состояния / некорректный ввод (`ErrConflict`, `ErrNotReady`, `ErrInvalidInput`, `ErrNotFound`, `layout.ErrCollision`) | пользователю (уже получил ответ на поверхности) | `DEBUG` |
| нарушенный инвариант хранилища/учёта (не безопасность данных: файлы уже разложены) | команде, «может стать проблемой» | `WARN` |
| сбой БД / ФС / недоступность зависимости | команде, в разбор | `ERROR` |
Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт: авто-
раскладка, поллинг) адресован уже команде как деградация автоматики — уровень
поднимается. Пример: `layout.ErrCollision` в ручном `Apply` — `DEBUG` (человек
видит причину в карточке), а в авто-раскладке — `WARN` («auto-apply failed,
left for review»): автоматика не довела задачу, это «может стать проблемой».
- **Повторяющийся сбой фонового цикла (поллинг/сверка) — `WARN`, не `ERROR`.**
Одиночный промах тика (`poll`/`sweep`/`list failed`, недоступный
qBittorrent) транзиентен: следующий тик повторит. Тот же класс сбоя внутри
синхронной операции (`ingest.Ingest`) — `ERROR`, потому что операция
провалилась целиком и повтора нет. То есть уровень задаёт не текст ошибки, а
наличие штатного ретрая: тик повторится → `WARN`, разовая операция упала →
`ERROR`. (Устойчивый сбой N тиков подряд эскалировать в `ERROR` — на будущее,
сейчас не реализовано.)
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о
поведении зависимости, не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
@@ -206,6 +244,15 @@ log.Error(err.Error())
быть большим) — только на `DEBUG`, с вычисткой секретов и обрезкой по длине.
- При сомнении — не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
`*url.Error` (стандартный `net/http`) встраивает полный URL запроса, а
секрет может жить прямо в нём: токен Telegram в пути (`…/bot<TOKEN>/…`),
`api_key` метабазы в query. Санитизируем на границе клиента **до** лога и
обёртки — `logging.SanitizeErr(err)` разворачивает `*url.Error` в
первопричину (URL отбрасывается, `errors.Is` на причину сохраняется).
Применяется в `ext.*`-обёртке (`ExtCall`), клиентах metadata и tgbot. Общее
правило: **секрет не кладём в URL, если у API есть заголовок** — тогда его
нет и в ошибке транспорта.
## Куда пишем и уровень
+8 -1
View File
@@ -248,12 +248,19 @@ qBittorrent, но: v1/v2/гибрид дают разные значения; п
```
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
2. правило сходимости папки при плане раскладки. ← «второй сезон»
2. правило сходимости папки при плане раскладки. ← «второй сезон» ✓ реализовано
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
(state_transition — вставить, когда захочется таймлайн/метрики)
```
> Шаг 2 (правило сходимости папки) реализован — change
> `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования
> влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря
> определяется существованием папки на диске (`os.Lstat`), а не только статусом
> ссылки; рассинхрон нескольких живых папок → review; in-app разрешение
> рассинхрона осознанно вне scope (ручной фикс на диске).
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
боль.
+1
View File
@@ -45,6 +45,7 @@ erDiagram
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
+5
View File
@@ -39,6 +39,11 @@ series/
- provider-id — на папке сериала.
- Сезоны — `Season 01`, файлы — `... SxxEyy`.
- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется
от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а
не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что
и первый. Несколько разных живых папок одного матча → review. Источник истины —
`openspec/specs/file-layout/` («Сходимость базы папки…»).
## Сопоставление источник → цель
+42 -12
View File
@@ -22,6 +22,7 @@ stateDiagram-v2
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса)
completed --> recognizing
@@ -50,10 +51,11 @@ stateDiagram-v2
done --> target_missing: сверка — цель удалена
done --> orphaned: сверка — источник пропал
done --> deleted: Удалить (delete)
target_missing --> recognizing: Привязать заново
target_missing --> orphaned: источник тоже пропал
target_missing --> deleted: источник тоже пропал
orphaned --> deleted: цель тоже удалена
target_missing --> deleted: Удалить / источник тоже пропал (сверка)
orphaned --> deleted: Удалить / цель тоже удалена (сверка)
target_missing --> done: healing (цель вернулась)
orphaned --> done: healing (источник вернулся)
@@ -95,7 +97,8 @@ stateDiagram-v2
[architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки).
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и
**Удалить**`deleted` (полное удаление, см. ниже).
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
@@ -119,6 +122,18 @@ stateDiagram-v2
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
больше не переоценивает (см. ниже).
**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
инварианта «источник неприкосновенен» — только по подтверждению) →
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
@@ -157,14 +172,26 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`.
`magnet_timeout`**редкий страховочный предохранитель** (дефолт `24h`), а
не рабочий механизм: долгий `metaDL` (медленные трекеры/мало пиров) — это
норма, его не убиваем агрессивно. Возраст считаем от времени добавления
торрента в qBittorrent (`added_on`), а не от создания задачи (базис
переживает retry и усыновление).
`magnet_timeout``failed`; `stalledDL` **простаивающий** дольше
`stuck_after``stuck`. `magnet_timeout` — **редкий страховочный
предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
(медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
мерит **длительность простоя** — от `last_activity` (последнее движение
данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
`stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
в `downloading` не ронял задачу снова на ближайшем тике.
- **ошибка:** `error`/`missingFiles``failed` (`error_code` `qbit_error`) —
это настоящий провал, в отличие от таймаута.
- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса
`source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла
из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code`
`source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби,
которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка
`source_gone` **не воскрешает** (удаление намеренно) — но задача штатно
retriable: `Retry` заново отдаёт сохранённый источник.
### Уведомление и восстановление
@@ -179,10 +206,13 @@ SQLite; `worker` периодически сверяет qBittorrent с БД и
только источник в qBittorrent ожил и продвинулся за условие падения
(получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
Настоящие провалы (`qbit_error`) сверкой не воскрешаются.
Настоящие провалы (`qbit_error`) и намеренная пропажа источника
(`source_gone`) сверкой не воскрешаются — только ручной retry.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
REST): возвращает в `downloading`, перецепляясь к живому торренту без
повторного `Add`.
REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
без повторного `Add` (к сломанному — `error`/`missingFiles` — не
перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
(`retried_at`), чтобы задача не упала снова на ближайшем тике.
Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
+2
View File
@@ -25,6 +25,7 @@ func (s stubCommander) Retry(context.Context, string) error { return s.retryErr
type actionReviewer struct {
stubReviewer
undoErr error
deleteErr error
relinkErr error
rerecognizeErr error
refineErr error
@@ -32,6 +33,7 @@ type actionReviewer struct {
}
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
+12 -1
View File
@@ -47,12 +47,18 @@ type downloadDetailView struct {
// Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView
// Nameable — доступно ручное обновление имени: есть распознанное название,
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
Nameable bool
// Действия по состоянию (как на главной).
Terminal bool
Reviewable bool
Undoable bool
Relinkable bool
Retriable bool
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
}
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
@@ -80,7 +86,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
s.deps.Logger.Error("download detail data", "id", id, "error", err)
s.deps.Logger.Error("download detail data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
@@ -111,6 +117,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
d.State == store.StateTargetMissing,
}
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем.
@@ -129,6 +137,9 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.Season = seasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
+17 -5
View File
@@ -24,6 +24,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/torrent"
@@ -133,10 +134,12 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
// REST API.
r.Route("/api", func(r chi.Router) {
@@ -746,20 +749,29 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404, валидация источника
// (magnet.ErrNotMagnet) → 400, недокачанный источник (worker.ErrNotReady) и
// конфликт состояния (worker.ErrConflict) → 409, прочее → 500. Полная ошибка
// уже в логах на доменной границе — наружу отдаём только сообщение +
// корреляционный ключ.
// деталей реализации): ErrNotFound → 404; валидация источника
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput)
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (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), errors.Is(err, torrent.ErrNotTorrent):
return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, worker.ErrInvalidInput):
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
// не сбой сервера.
return http.StatusBadRequest, "некорректный ввод"
case errors.Is(err, worker.ErrNotReady):
// Источник ещё качается — actionable причина, показываем конкретно.
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
case errors.Is(err, layout.ErrCollision):
// Целевой путь уже занят: задача штатно ушла в review с причиной —
// это не сбой, а требующий разбора конфликт.
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии"
+126
View File
@@ -247,6 +247,41 @@ func TestAPICommandNotReady(t *testing.T) {
}
}
func TestAPICommandInvalidInput(t *testing.T) {
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
cmd := &fakeCommander{err: fmt.Errorf("set type: invalid type %q: %w", "foo", worker.ErrInvalidInput)}
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.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestAPICommandCollision(t *testing.T) {
// Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500.
cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)}
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},
@@ -454,9 +489,11 @@ type fakeReviewer struct {
applied []string
deferred []string
undone []string
deleted []string
relinked []string
rerecognized []string
cleared []string
nameRefreshed []string
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
@@ -491,6 +528,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id string) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
f.relinked = append(f.relinked, id)
return nil
@@ -524,6 +565,10 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
}
func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
f.nameRefreshed = append(f.nameRefreshed, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
@@ -677,6 +722,87 @@ func TestAddManualSource(t *testing.T) {
}
}
func TestRefreshNameNonHTMXRedirectsToDownload(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
// Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список.
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
t.Errorf("Location = %q, want /download/%s", loc, tid)
}
}
func TestRefreshNameHTMXSwapsMain(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
req.Header.Set("HX-Request", "true")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `id="download-main"`) {
t.Error("htmx-ответ не содержит фрагмент download-main")
}
if len(rv.nameRefreshed) != 1 {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
}
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
// done (есть план) она всё равно доступна.
rd := seriesReviewData()
rd.Download.State = store.StateDone
rv := &fakeReviewer{data: rd}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "/refresh-name") {
t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания")
}
}
func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) {
rv := &fakeReviewer{data: &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "/refresh-name") {
t.Error("кнопка «Обновить имя» показана без распознавания")
}
}
func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
+2
View File
@@ -49,6 +49,7 @@ func (stubReviewer) SetType(context.Context, string, string) error
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
func (stubReviewer) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
func (stubReviewer) Delete(context.Context, string) error { return nil }
func (stubReviewer) Relink(context.Context, string) error { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
@@ -57,6 +58,7 @@ func (stubReviewer) AddManualSource(context.Context, string, string, string) err
return nil
}
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
func (stubReviewer) RefreshDisplayName(context.Context, string) error { return nil }
// stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live }
+41 -3
View File
@@ -21,12 +21,14 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
Relink(ctx context.Context, id string) error
Rerecognize(ctx context.Context, id string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
SetProviderID(ctx context.Context, id string, provider, providerID string) error
AddManualSource(ctx context.Context, id, provider, providerID string) error
ClearProvider(ctx context.Context, id string) error
RefreshDisplayName(ctx context.Context, id string) error
}
// --- Представление страницы ревью ---
@@ -86,7 +88,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
s.deps.Logger.Error("review data", "id", id, "error", err)
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
@@ -228,6 +230,29 @@ func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
})
}
// handleRefreshName переливает распознанное каноническое имя в display_name и в
// ярлык раздачи (ручная кнопка на странице загрузки). Свопит главную область
// (#download-main) с обновлённым заголовком; без htmx — PRG на страницу загрузки
// (в отличие от surfaceAction, уводящего на список: смысл действия — увидеть
// новое имя здесь же). Ошибку на htmx-пути показываем 200 + фрагментом.
func (s *server) handleRefreshName(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := s.deps.Reviewer.RefreshDisplayName(r.Context(), id)
if !isHTMX(r) {
if actionErr != nil {
redirectErr(w, r, userErr(r, actionErr, id))
return
}
http.Redirect(w, r, "/download/"+id, http.StatusSeeOther)
return
}
s.renderDownloadFragment(w, r, id, actionErr)
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")
@@ -332,6 +357,19 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
}
// handleDelete — полное удаление загрузки (снять хардлинки + снести раздачу с
// файлами из qBittorrent → deleted). Осознанное необратимое действие: транспорт
// подтверждает его перед POST (hx-confirm + отдельная danger-секция). Ошибку
// qBittorrent surfaceAction покажет как отказ (не тихий успех).
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
@@ -369,7 +407,7 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
@@ -429,7 +467,7 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
// и рендерим свежий партиал блока.
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
+25 -7
View File
@@ -35,6 +35,11 @@ type Store interface {
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
// UpgradeCatchedMagnetToTorrent при дедупе входящих байтов `.torrent` на
// пойманную (`catched`) magnet-задачу сохраняет байты и меняет source_type
// на torrent (атомарно). No-op, если задача уже добавлена/не magnet. Чинит
// magnet закрытого трекера, который иначе застрянет в metaDL.
UpgradeCatchedMagnetToTorrent(ctx context.Context, downloadID string, torrentBlob []byte) (bool, error)
}
// Service — реализация быстрого приёма.
@@ -72,6 +77,9 @@ type Result struct {
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
src, err := s.parse(req)
if err != nil {
// Невалидный источник — норма (адресат не команда, а пользователь, и он
// получит отказ на транспорте): DEBUG, чтобы не шуметь в аудите.
s.log.Debug("ingest source rejected", "capability", capIngest, "error", err)
return Result{}, err
}
@@ -83,6 +91,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
// гибридный несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "lookup-active", "error", err)
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)
@@ -104,19 +114,16 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// транзакции лишь на ветке создания.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
if err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "create-download", "error", err)
return Result{}, fmt.Errorf("ingest: create download: %w", err)
}
if existing != nil {
// Гонка с параллельным приёмом/discover: активная задача появилась
// после быстрого чека — присоединяемся к ней (хеши донёс сам
// CreateDownloadIfNoActive).
// CreateDownloadIfNoActive; existing уже с подгруженными хешами).
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{
DownloadID: existing.ID,
Infohashes: src.infohashes,
State: existing.State,
Deduplicated: true,
}, nil
return s.attached(ctx, src, existing), nil
}
log.Info("download catched", "download_id", d.ID)
@@ -208,6 +215,17 @@ func (s *Service) attached(ctx context.Context, src parsedSource, existing *stor
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
}
}
// F6: входящее — байты `.torrent`, а активная задача поймана как magnet и
// ещё не отдана в qBittorrent (catched) → сохраняем байты и переключаем
// источник на torrent, чтобы воркер добавил раздачу файлом (magnet
// закрытого трекера иначе застрянет в metaDL). Best-effort: не валит приём.
if src.sourceType == store.SourceTorrent && len(src.torrentBlob) > 0 {
if upgraded, err := s.store.UpgradeCatchedMagnetToTorrent(ctx, existing.ID, src.torrentBlob); err != nil {
logctx.FromOr(ctx, s.log).Warn("ingest torrent upgrade failed", "error", err)
} else if upgraded {
logctx.FromOr(ctx, s.log).Info("catched magnet upgraded to torrent", "download_id", existing.ID)
}
}
return Result{
DownloadID: existing.ID,
Infohashes: src.infohashes,
+9
View File
@@ -21,6 +21,9 @@ type fakeStore struct {
hashes [][]string
blobs [][]byte
toppedUp []string
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
upgradeBlob []byte // байты, переданные в апгрейд
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
@@ -44,6 +47,12 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
return nil
}
func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string, blob []byte) (bool, error) {
f.upgradeID = id
f.upgradeBlob = blob
return f.upgradeUp, nil
}
func newService(st Store) *Service {
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
+39
View File
@@ -83,6 +83,45 @@ func TestIngestTorrentDedupNoBlob(t *testing.T) {
}
}
// F6: дедуп .torrent на пойманную (catched) magnet-задачу вызывает апгрейд —
// сохранение байтов и смену источника (magnet закрытого трекера иначе застрянет
// в metaDL).
func TestIngestTorrentUpgradesCatchedMagnet(t *testing.T) {
data, hash := buildTorrent(t, "Dune", "http://t/ann")
existing := &store.Download{
ID: "cm",
State: store.StateCatched,
SourceType: store.SourceMagnet,
Infohashes: []store.Infohash{{Infohash: hash, Kind: store.HashV1}},
}
fs := &fakeStore{active: existing, upgradeUp: true}
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if !res.Deduplicated || res.DownloadID != "cm" {
t.Errorf("ожидался дедуп на cm, res = %+v", res)
}
if fs.upgradeID != "cm" {
t.Errorf("апгрейд не вызван для существующей задачи (upgradeID=%q)", fs.upgradeID)
}
if !bytes.Equal(fs.upgradeBlob, data) {
t.Errorf("в апгрейд переданы не те байты")
}
}
// Дедуп magnet-ссылки (не .torrent) апгрейд не вызывает — нечего сохранять.
func TestIngestMagnetDedupNoUpgrade(t *testing.T) {
existing := &store.Download{ID: "cm", State: store.StateCatched, SourceType: store.SourceMagnet}
fs := &fakeStore{active: existing}
if _, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet}); err != nil {
t.Fatalf("Ingest: %v", err)
}
if fs.upgradeID != "" {
t.Errorf("апгрейд не должен вызываться для magnet-дедупа")
}
}
func TestIngestTorrentTooLarge(t *testing.T) {
fs := &fakeStore{}
big := make([]byte, MaxTorrentSize+1)
+66 -1
View File
@@ -20,6 +20,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"syscall"
"git.vakhrushev.me/av/jellybit/internal/logctx"
@@ -68,6 +69,12 @@ type Plan struct {
Title string
Year int
ProviderTag string // напр. "tmdbid-693134"; пусто — без тега
// FolderBase — унаследованная от живого якоря база имени ("Название (Год)").
// Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто →
// перекрывает Title/Year и идёт и в папку, и в имена файлов (правило
// сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно
// из ProviderTag.
FolderBase string
Files []PlanFile
}
@@ -136,7 +143,7 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
if err != nil {
return nil, err
}
base, err := titleYear(p.Title, p.Year)
base, err := planBase(p)
if err != nil {
return nil, err
}
@@ -209,6 +216,36 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
}
}
// TitleFolder разбирает целевой путь dst живой ссылки в папку тайтла и её базу
// имени для правила сходимости (см. file-layout spec). Возвращает абсолютный
// путь папки тайтла (первый сегмент под корнем библиотеки типа t), её базу
// (снят хвостовой provider-тег) и ok. ok=false, если dst не под корнем нужной
// библиотеки или база пуста — вызывающий трактует как «нет якоря». Существование
// папки на диске здесь НЕ проверяется (это делает worker через os.Lstat).
func (l *Layouter) TitleFolder(t MediaType, dst string) (dir, base string, ok bool) {
root, err := l.root(t)
if err != nil {
return "", "", false
}
dst = filepath.Clean(dst)
if !underRoot(root, dst) {
return "", "", false
}
rel, err := filepath.Rel(root, dst)
if err != nil {
return "", "", false
}
first, _, _ := strings.Cut(rel, string(filepath.Separator))
if first == "" || first == "." {
return "", "", false
}
base = folderBase(first)
if base == "" {
return "", "", false
}
return filepath.Join(root, first), base, true
}
// LinkStatus — исход создания одной ссылки.
type LinkStatus string
@@ -434,6 +471,34 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
return removed, nil
}
// Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo,
// который отказывается стирать единственную копию данных. Служит осознанному
// удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку,
// даже если источник уже пропал. Как и Undo, трогает только пути строго под
// библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели.
// Возвращает число удалённых ссылок.
func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
removed := 0
for _, ln := range links {
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
log.Error("layout remove failed", "dst", ln.Dst, "error", err)
return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err)
}
removed++
log.Debug("layout link removed (delete)", "dst", ln.Dst)
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
}
return removed, nil
}
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
+72
View File
@@ -98,6 +98,78 @@ func TestBuildLinks_Series(t *testing.T) {
}
}
func TestBuildLinks_FolderBaseOverridesSeries(t *testing.T) {
f := newFixture(t)
// LLM дал «Fargo» 2017, но живой якорь — «Фарго (2014)». FolderBase должна
// перекрыть Title/Year и в папке, И в имени файла.
plan := Plan{
Type: Series, Title: "Fargo", Year: 2017, ProviderTag: "tvdbid-269613",
FolderBase: "Фарго (2014)",
Files: []PlanFile{
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
},
}
links, err := f.l.BuildLinks(plan)
if err != nil {
t.Fatalf("BuildLinks: %v", err)
}
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
if links[0].Dst != want {
t.Errorf("ep = %q, want %q", links[0].Dst, want)
}
}
func TestBuildLinks_FolderBaseOverridesMovie(t *testing.T) {
f := newFixture(t)
plan := Plan{
Type: Movie, Title: "Dune", Year: 2021, ProviderTag: "tmdbid-693134",
FolderBase: "Дюна Часть вторая (2024)",
Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}},
}
links, err := f.l.BuildLinks(plan)
if err != nil {
t.Fatalf("BuildLinks: %v", err)
}
want := filepath.Join(f.movies, "Дюна Часть вторая (2024) [tmdbid-693134]", "Дюна Часть вторая (2024).mkv")
if links[0].Dst != want {
t.Errorf("main = %q, want %q", links[0].Dst, want)
}
}
func TestTitleFolder(t *testing.T) {
f := newFixture(t)
epDst := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 01", "Фарго (2014) S01E01.mkv")
dir, base, ok := f.l.TitleFolder(Series, epDst)
if !ok {
t.Fatal("want ok for path under series root")
}
if wantDir := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]"); dir != wantDir {
t.Errorf("dir = %q, want %q", dir, wantDir)
}
if base != "Фарго (2014)" {
t.Errorf("base = %q, want %q", base, "Фарго (2014)")
}
// Фильм: папка тайтла — первый сегмент, база без тега.
mvDst := filepath.Join(f.movies, "Dune (2024) [tmdbid-1]", "Dune (2024).mkv")
if dir, base, ok := f.l.TitleFolder(Movie, mvDst); !ok ||
dir != filepath.Join(f.movies, "Dune (2024) [tmdbid-1]") || base != "Dune (2024)" {
t.Errorf("movie: dir=%q base=%q ok=%v", dir, base, ok)
}
// Путь не под корнем нужной библиотеки → not ok.
if _, _, ok := f.l.TitleFolder(Series, mvDst); ok {
t.Error("movie path must not resolve under series root")
}
if _, _, ok := f.l.TitleFolder(Movie, "/etc/passwd"); ok {
t.Error("path outside library must be rejected")
}
// Сам корень (нет сегмента папки) → not ok.
if _, _, ok := f.l.TitleFolder(Movie, f.movies); ok {
t.Error("root itself has no title folder")
}
}
func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) {
f := newFixture(t)
plan := Plan{
+27
View File
@@ -40,6 +40,20 @@ func titleYear(title string, year int) (string, error) {
return t, nil
}
// planBase выбирает базу имени плана: унаследованная от живого якоря
// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом
// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен).
func planBase(p Plan) (string, error) {
if p.FolderBase != "" {
b := sanitizeComponent(p.FolderBase)
if b == "" {
return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase)
}
return b, nil
}
return titleYear(p.Title, p.Year)
}
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
func folderName(base, providerTag string) string {
tag := sanitizeComponent(providerTag)
@@ -49,6 +63,19 @@ func folderName(base, providerTag string) string {
return fmt.Sprintf("%s [%s]", base, tag)
}
// folderBase восстанавливает базу имени ("Название (Год)") из имени папки
// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий:
// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "["
// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий
// трактует как «нет якоря»).
func folderBase(folder string) string {
base := folder
if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") {
base = folder[:i]
}
return sanitizeComponent(base)
}
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
func seasonFolder(season int) string {
return fmt.Sprintf("Season %02d", season)
+19
View File
@@ -47,6 +47,25 @@ func TestFolderName(t *testing.T) {
}
}
func TestFolderBase(t *testing.T) {
tests := []struct {
in, want string
}{
{"Фарго (2014) [tvdbid-269613]", "Фарго (2014)"}, // текущий тег
{"Fargo (2017) [tmdbid-123]", "Fargo (2017)"}, // чужой/старый тег снимается так же
{"No Tag (2020)", "No Tag (2020)"}, // без тега — как есть
{"Bare Name", "Bare Name"}, // без года и тега
{"Weird [not a tag", "Weird [not a tag"}, // незакрытый — не трогаем
{"[tvdbid-1]", "[tvdbid-1]"}, // нет " [" с пробелом — не тег
{"Movie (2020) [edition-Director's Cut]", "Movie (2020)"}, // снимается любой хвостовой [...]
}
for _, tt := range tests {
if got := folderBase(tt.in); got != tt.want {
t.Errorf("folderBase(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestEpisodeStem(t *testing.T) {
if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" {
t.Errorf("got %q", got)
+6 -3
View File
@@ -54,12 +54,15 @@ func (c ExtCall) SuccessDebug(log *slog.Logger, extra ...any) {
log.Debug("external call", c.attrs(extra...)...)
}
// Retry логирует неудачную попытку, после которой будет повтор (WARN).
// Retry логирует неудачную попытку, после которой будет повтор (WARN). Ошибка
// санитизируется (SanitizeErr): транспортный сбой несёт URL с секретом
// (api_key TMDB в query, токен в пути) — в лог он попасть не должен.
func (c ExtCall) Retry(log *slog.Logger, err error, extra ...any) {
log.Warn("external call retry", c.attrs(append([]any{"error", err}, extra...)...)...)
log.Warn("external call retry", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
}
// Failure логирует окончательную неудачу вызова / недоступность сервиса (ERROR).
// Ошибка санитизируется (SanitizeErr) — см. Retry.
func (c ExtCall) Failure(log *slog.Logger, err error, extra ...any) {
log.Error("external call failed", c.attrs(append([]any{"error", err}, extra...)...)...)
log.Error("external call failed", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...)
}
+24
View File
@@ -0,0 +1,24 @@
package logging
import (
"errors"
"net/url"
)
// SanitizeErr убирает из ошибки HTTP-транспорта URL запроса и возвращает только
// первопричину. Ошибки клиента net/http — `*url.Error`, чей текст встраивает
// полный URL, а URL может нести секрет: токен бота Telegram в пути
// (`…/bot<TOKEN>/<method>`) или api_key TMDB в query
// (`…/search/movie?api_key=<SECRET>&…`). Логировать или оборачивать такую ошибку
// как есть нельзя — секрет утечёт в логи (инвариант «секреты не в логи», см.
// logging.md, раздел «Безопасность»).
//
// Логическая операция при этом не теряется: она пишется отдельным полем
// (`ext.operation`) на границе клиента. Не `*url.Error` — возвращаем как есть.
func SanitizeErr(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
+63
View File
@@ -0,0 +1,63 @@
package logging
import (
"errors"
"fmt"
"net/url"
"strings"
"testing"
)
// SanitizeErr обязана убрать URL (носитель секрета) из *url.Error, оставив
// первопричину — это единый предохранитель от утечки токена/ключа в логи.
func TestSanitizeErr(t *testing.T) {
cause := errors.New("dial tcp 1.2.3.4:443: connect: connection refused")
t.Run("url error with token in path", func(t *testing.T) {
ue := &url.Error{
Op: "Get",
URL: "https://api.telegram.org/botSECRET123:AAtoken/getUpdates",
Err: cause,
}
got := SanitizeErr(ue)
if strings.Contains(got.Error(), "SECRET123") || strings.Contains(got.Error(), "AAtoken") {
t.Fatalf("токен утёк: %v", got)
}
if !errors.Is(got, cause) {
t.Fatalf("первопричина потеряна: %v", got)
}
})
t.Run("url error with api_key in query", func(t *testing.T) {
ue := &url.Error{
Op: "Get",
URL: "https://api.themoviedb.org/3/search/movie?api_key=DEADBEEFSECRET&query=dune",
Err: cause,
}
if strings.Contains(SanitizeErr(ue).Error(), "DEADBEEFSECRET") {
t.Fatalf("api_key утёк: %v", SanitizeErr(ue))
}
})
t.Run("wrapped url error is unwrapped via errors.As", func(t *testing.T) {
wrapped := fmt.Errorf("metadata: request: %w", &url.Error{
Op: "Get", URL: "https://x/api_key=SECRET", Err: cause,
})
if strings.Contains(SanitizeErr(wrapped).Error(), "SECRET") {
t.Fatalf("секрет утёк из обёрнутой ошибки: %v", SanitizeErr(wrapped))
}
})
t.Run("non-url error passes through unchanged", func(t *testing.T) {
plain := errors.New("plain domain error")
if !errors.Is(SanitizeErr(plain), plain) {
t.Fatalf("обычная ошибка изменена: %v", SanitizeErr(plain))
}
})
t.Run("nil stays nil", func(t *testing.T) {
if SanitizeErr(nil) != nil {
t.Fatal("nil должен оставаться nil")
}
})
}
+4
View File
@@ -79,6 +79,10 @@ func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, ope
call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()}
resp, err := hc.Do(req)
if err != nil {
// Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key —
// query-параметр: санитизируем до логирования И до обёртки, чтобы ключ
// не утёк ни в лог, ни вверх по цепочке %w.
err = logging.SanitizeErr(err)
call.Failure(log, err)
return fmt.Errorf("metadata: request: %w", err)
}
+22
View File
@@ -69,6 +69,12 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
return ""
}
if n.provider != nil {
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
if name := render(ex); name != "" {
@@ -79,6 +85,22 @@ func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string
return fallbackName(contextText, hint)
}
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
// распознанного названия — без сети и LLM (перелив канонического имени после
// распознавания). Год опционален; имя очищается от управляющих символов и
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
func FormatTitleYear(title string, year int) string {
title = sanitize(title)
if title == "" {
return ""
}
if year > 0 {
title += " (" + strconv.Itoa(year) + ")"
}
return truncate(title, maxNameLen)
}
// render собирает из структуры короткий ярлык:
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
// - series: то же + ". Сезон N", если сезон задан.
+47
View File
@@ -49,6 +49,53 @@ func TestDeriveNameViaLLM(t *testing.T) {
}
}
func TestDeriveNameEmptyInputSkipsLLM(t *testing.T) {
// Голый magnet без dn/контекста: выводить имя не из чего — LLM не зовём.
fp := &fakeProvider{responses: []string{
`{"type":"movie","title":"Unknown"}`, // как раз мусор, который был бы записан
}}
got := New(fp, 3, testLogger()).DeriveName(context.Background(), " ", " ")
if got != "" {
t.Errorf("DeriveName() = %q, want empty", got)
}
if fp.calls != 0 {
t.Errorf("вызовов LLM = %d, want 0 (пустой вход не зовёт модель)", fp.calls)
}
}
func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
// Контекст пуст, но есть подсказка (dn) — гард пустого входа НЕ срабатывает:
// имя выводится фолбеком из подсказки. LLM здесь не зовётся (он работает
// только по контексту, а его нет) — это прежнее поведение, гард его не меняет.
fp := &fakeProvider{responses: []string{`{"type":"movie","title":"x"}`}}
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "", "Dune.Part.Two.2024")
if got == "" {
t.Error("при наличии подсказки имя не должно быть пустым (гард не должен глотать hint)")
}
if fp.calls != 0 {
t.Errorf("вызовов LLM = %d, want 0 (LLM выводит по контексту, которого нет)", fp.calls)
}
}
func TestFormatTitleYear(t *testing.T) {
cases := []struct {
title string
year int
want string
}{
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
{"Брат", 0, "Брат"},
{" Fargo\n", 2015, "Fargo (2015)"},
{"", 2024, ""},
{" ", 0, ""},
}
for _, c := range cases {
if got := FormatTitleYear(c.title, c.year); got != c.want {
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want)
}
}
}
func TestDeriveNameRussianTitle(t *testing.T) {
fp := &fakeProvider{responses: []string{
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
+96
View File
@@ -60,6 +60,11 @@ type Torrent struct {
AmountLeft int64 `json:"amount_left"`
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
AddedOn int64 `json:"added_on"`
// LastActivity — Unix-время последнего движения данных по торренту (скачан/
// отдан кусок). Базис измерения простоя для stuck_after: простой = now
// last_activity (а не возраст от added_on), поэтому долго качавшийся торрент,
// на миг зашедший в stalledDL, не помечается «зависшим».
LastActivity int64 `json:"last_activity"`
InfohashV1 string `json:"infohash_v1"`
InfohashV2 string `json:"infohash_v2"`
@@ -251,6 +256,97 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
return nil
}
// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
// трогает — их удаляет qBittorrent по этому вызову.
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
nonEmpty := hashes[:0:0]
for _, h := range hashes {
if h = strings.TrimSpace(h); h != "" {
nonEmpty = append(nonEmpty, h)
}
}
if len(nonEmpty) == 0 {
return fmt.Errorf("qbittorrent delete: no hashes")
}
form := url.Values{
"hashes": {strings.Join(nonEmpty, "|")},
"deleteFiles": {strconv.FormatBool(deleteFiles)},
}
body := form.Encode()
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()}
resp, err := c.do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", c.base.String())
return req, nil
})
if err != nil {
call.Failure(log, err)
return fmt.Errorf("qbittorrent delete: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
err := fmt.Errorf("qbittorrent delete: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(respBody)))
call.Failure(log, err)
return err
}
call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
return nil
}
// RenameTorrent задаёт отображаемое имя уже добавленной раздачи (в отличие от
// параметра rename при Add, действующего только в момент добавления). hash —
// ключ раздачи в qBittorrent (Torrent.Hash), а не сырой infohash загрузки:
// вызывающий резолвит его листингом. Косметика: имя раздачи не влияет на файлы.
func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
hash = strings.TrimSpace(hash)
if hash == "" {
return fmt.Errorf("qbittorrent rename: empty hash")
}
form := url.Values{"hash": {hash}, "name": {name}}
body := form.Encode()
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/rename", Start: time.Now()}
resp, err := c.do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", c.base.String())
return req, nil
})
if err != nil {
call.Failure(log, err)
return fmt.Errorf("qbittorrent rename: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
err := fmt.Errorf("qbittorrent rename: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(respBody)))
call.Failure(log, err)
return err
}
call.Success(log)
return nil
}
// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
log := logctx.FromOr(ctx, c.log)
+77
View File
@@ -114,6 +114,83 @@ func TestAddSendsRename(t *testing.T) {
}
}
func TestDeleteSendsHashesAndDeleteFiles(t *testing.T) {
var gotHashes, gotDeleteFiles string
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/delete", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotHashes = r.PostForm.Get("hashes")
gotDeleteFiles = r.PostForm.Get("deleteFiles")
_, _ = w.Write([]byte("Ok."))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
err := c.Delete(context.Background(), []string{"aaa", "", "bbb"}, true)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if gotHashes != "aaa|bbb" { // пустые хеши отфильтрованы, склейка через |
t.Errorf("hashes = %q, want %q", gotHashes, "aaa|bbb")
}
if gotDeleteFiles != "true" {
t.Errorf("deleteFiles = %q, want true", gotDeleteFiles)
}
}
func TestRenameTorrentSendsHashAndName(t *testing.T) {
var gotHash, gotName string
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotHash = r.PostForm.Get("hash")
gotName = r.PostForm.Get("name")
_, _ = w.Write([]byte("Ok."))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
if err := c.RenameTorrent(context.Background(), "aaa", "Harold and the Purple Crayon (2024)"); err != nil {
t.Fatalf("RenameTorrent: %v", err)
}
if gotHash != "aaa" {
t.Errorf("hash = %q, want aaa", gotHash)
}
if gotName != "Harold and the Purple Crayon (2024)" {
t.Errorf("name = %q", gotName)
}
}
func TestRenameTorrentEmptyHashIsError(t *testing.T) {
c := newClient(t, "http://unused")
if err := c.RenameTorrent(context.Background(), " ", "x"); err == nil {
t.Fatal("RenameTorrent with empty hash must error before any request")
}
}
func TestRenameTorrentNon200IsError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Found", http.StatusNotFound)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
if err := c.RenameTorrent(context.Background(), "aaa", "x"); err == nil {
t.Fatal("RenameTorrent must error on non-200")
}
}
func TestDeleteNoHashesIsError(t *testing.T) {
c := newClient(t, "http://unused")
if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil {
t.Fatal("Delete with no hashes must error before any request")
}
}
func TestAddPerformsLazyLogin(t *testing.T) {
srv := fakeQBittorrent(t, "[]")
c := newClient(t, srv.URL)
+44 -7
View File
@@ -22,6 +22,14 @@ const maxCandidates = 8
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
//
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 {
return nil, nil
@@ -32,14 +40,29 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
}
matchTitles := normSet(plan.Title, plan.OriginalTitle)
keys := searchKeys(plan)
// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
// При неизвестном годе второй проход был бы идентичен первому — не делаем.
queryYears := []int{plan.Year}
if plan.Year > 0 {
queryYears = append(queryYears, 0)
}
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, key := range searchKeys(plan) {
for _, qYear := range queryYears {
// Безгодовой fallback-проход (есть только при известном годе плана).
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
// запись с неизвестным годом — только кандидат в review (год подтвердить
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
// к unknown-году сохраняется как прежде.
fallback := qYear == 0 && plan.Year > 0
for _, key := range keys {
for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
if err != nil {
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
// доменное решение «пропускаем провайдера, пробуем следующий».
@@ -58,10 +81,13 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
}
// Единичный сильный матч ищем у первого подходящего провайдера.
// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
// выдачу запроса, но требует год кандидата ±1 (и известный — в
// fallback), поэтому условие подтверждения не ослабляется.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles)
strong := strongMatches(cands, plan.Year, matchTitles, fallback)
if len(strong) != 1 {
continue
}
@@ -71,6 +97,10 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
break
}
}
if match != nil {
break // pass 1 подтвердил матч — безгодовой проход не нужен
}
}
return match, candidates
}
@@ -129,11 +159,15 @@ func CandidateTag(c metadata.Candidate) (provider, id string) {
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
// по id.
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
seen := map[string]bool{}
var out []metadata.Candidate
for _, c := range cands {
if requireKnownYear && c.Year == 0 {
continue
}
if !yearMatches(year, c.Year) {
continue
}
@@ -175,11 +209,14 @@ func normSet(titles ...string) map[string]bool {
// normalize приводит название к сравнимому виду: нижний регистр, только
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
// расхождение написания: «Тёмный» vs «Темный»).
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
// устойчив к ним независимо от санитайзинга плана.
func normalize(s string) string {
var b strings.Builder
prevSpace := false
for _, r := range strings.ToLower(s) {
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
if r == 'ё' {
r = 'е'
}
+123 -2
View File
@@ -15,6 +15,7 @@ type fakeProvider struct {
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
counts map[int]int
searchErr error
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
searched int
queries []string // строки запросов в порядке вызова
}
@@ -31,10 +32,20 @@ func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.C
if f.searchErr != nil {
return nil, f.searchErr
}
res := f.candidates
if f.byTitle != nil {
return f.byTitle[q.Title], nil
res = f.byTitle[q.Title]
}
return f.candidates, nil
if f.exactYear && q.Year > 0 {
var filtered []metadata.Candidate
for _, c := range res {
if c.Year == q.Year {
filtered = append(filtered, c)
}
}
return filtered, nil
}
return res, nil
}
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
return f.counts, nil
@@ -264,6 +275,105 @@ func TestMatchMetadata_Disabled(t *testing.T) {
}
}
func TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) {
// Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1,
// безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023})
if m == nil || m.ProviderID != "42" {
t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m)
}
if p.searched != 2 {
t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched)
}
}
func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) {
// Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её
// отклонит — подтверждённого матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "7", Title: "X", Year: 2005},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2010})
if m != nil {
t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "7" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) {
// Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её
// не находит, pass 2 (без года) находит, но в fallback требуется известный
// год → авто-матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "9", Title: "X", Year: 0},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020})
if m != nil {
t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "9" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) {
// pass 1 подтвердил матч — безгодовой проход не выполняется.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "X", Year: 2000},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000})
if m == nil {
t.Fatal("ожидался матч в pass 1")
}
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched)
}
}
func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) {
// Год неизвестен (0): второй проход был бы идентичен первому — не делаем.
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Y", Year: 2000},
}}
r := recognizerWith(p)
// Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт;
// проверяем именно число запросов.
r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"})
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched)
}
}
func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) {
// Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001},
{Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Twin", Year: 2000})
if m != nil {
t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m)
}
if len(cands) != 2 {
t.Errorf("оба кандидата должны собраться для review: %+v", cands)
}
}
func TestNormalize(t *testing.T) {
cases := map[string]string{
"The Matrix": "the matrix",
@@ -280,6 +390,17 @@ func TestNormalize(t *testing.T) {
}
}
func TestNormalize_FoldsHomoglyph(t *testing.T) {
// «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове
// нормализуется к тому же виду, что и чистая латиница (defense-in-depth на
// стороне кандидата базы).
dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица
clean := "Harold and the Purple Crayon"
if normalize(dirty) != normalize(clean) {
t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean))
}
}
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
func TestRecognize_AutoWithMatch(t *testing.T) {
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
+1 -1
View File
@@ -212,7 +212,7 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
}
raw = resp.Content
plan, parseErr = parsePlan(raw, in)
plan, parseErr = parsePlan(raw, in, log)
if parseErr == nil {
break
}
+161
View File
@@ -0,0 +1,161 @@
package recognize
import (
"log/slog"
"strings"
"unicode"
)
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
// пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных
// названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны:
// направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена.
// Единственный источник правды и для санитайзинга плана, и для нормализации в
// гейте матча (normalize).
var homoglyphPairs = []struct{ cyr, lat rune }{
// строчные
{'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'},
{'у', 'y'}, {'х', 'x'}, {'к', 'k'},
// заглавные
{'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'},
{'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'},
{'Х', 'X'},
}
var (
cyr2lat = map[rune]rune{}
lat2cyr = map[rune]rune{}
)
func init() {
for _, p := range homoglyphPairs {
cyr2lat[p.cyr] = p.lat
lat2cyr[p.lat] = p.cyr
}
}
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
func sanitizeTitle(s string) string {
s = stripControl(s)
s = collapseSpaces(s)
s = foldHomoglyphs(s)
return s
}
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
// а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось
// (названия не относятся к секретам).
func sanitizePlan(p *Plan, log *slog.Logger) {
p.Title = sanitizeField(p.Title, "title", log)
p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log)
p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log)
}
func sanitizeField(v, field string, log *slog.Logger) string {
clean := sanitizeTitle(v)
if clean != v && log != nil {
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
}
return clean
}
// stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория
// Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт
// collapseSpaces).
func stripControl(s string) string {
return strings.Map(func(r rune) rune {
switch {
case unicode.Is(unicode.Cf, r):
return -1
case unicode.IsControl(r) && !unicode.IsSpace(r):
return -1
default:
return r
}
}, s)
}
// collapseSpaces сводит последовательности пробельных к одиночному пробелу и
// обрезает края.
func collapseSpaces(s string) string {
var b strings.Builder
b.Grow(len(s))
pendingSpace := false
for _, r := range s {
if unicode.IsSpace(r) {
pendingSpace = true
continue
}
if pendingSpace && b.Len() > 0 {
b.WriteByte(' ')
}
pendingSpace = false
b.WriteRune(r)
}
return b.String()
}
// foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно.
// Токен (максимальная последовательность букв) из одного скрипта не трогаем —
// билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене
// определяем доминирующий скрипт по числу буквенных рун и заменяем
// буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве
// скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация)
// разделяют токены и копируются как есть.
func foldHomoglyphs(s string) string {
runes := []rune(s)
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(runes); {
if !unicode.IsLetter(runes[i]) {
b.WriteRune(runes[i])
i++
continue
}
j := i
for j < len(runes) && unicode.IsLetter(runes[j]) {
j++
}
b.WriteString(foldToken(runes[i:j]))
i = j
}
return b.String()
}
func foldToken(tok []rune) string {
var cyr, lat int
for _, r := range tok {
switch {
case unicode.Is(unicode.Cyrillic, r):
cyr++
case unicode.Is(unicode.Latin, r):
lat++
}
}
if cyr == 0 || lat == 0 {
return string(tok) // одно-скриптовый токен — не трогаем
}
var m map[rune]rune
switch {
case lat > cyr:
m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники
case cyr > lat:
m = lat2cyr
default:
return string(tok) // нет доминирующего скрипта — не трогаем
}
out := make([]rune, len(tok))
for i, r := range tok {
if repl, ok := m[r]; ok {
out[i] = repl
} else {
out[i] = r
}
}
return string(out)
}
+102
View File
@@ -0,0 +1,102 @@
package recognize
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestFoldHomoglyphs(t *testing.T) {
cases := map[string]string{
// Latin-доминантный токен: кириллический двойник 'а' (U+0430) → 'a'.
"Hаrold and the Purple Crayon": "Harold and the Purple Crayon",
// Честная кириллица — не трогаем.
"Тёмный рыцарь": "Тёмный рыцарь",
// Честная латиница — не трогаем.
"The Dark Knight": "The Dark Knight",
// Cyrillic-доминантный токен (М р з) с латинскими 'o' (U+006F) → 'о'.
"Мoрoз": "Мороз",
// Равенство скриптов в токене (кир. О U+041E + лат. k) — не трогаем.
"Оk": "Оk",
// Двуязычные, но одно-скриптовые токены — каждый нетронут.
"Fargo Фарго": "Fargo Фарго",
// Буква-меньшинство без двойника в таблице (кир. 'б' U+0431) остаётся.
"Harбld": "Harбld",
// Пустая строка.
"": "",
}
for in, want := range cases {
if got := foldHomoglyphs(in); got != want {
t.Errorf("foldHomoglyphs(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeTitle(t *testing.T) {
cases := map[string]string{
// Zero-width (U+200B) внутри слова удаляется, слово склеивается.
"a\u200bb": "ab",
// Zero-width + двойник + лишние пробелы → strip + fold + collapse + trim.
" Hаrold\u200b Crayon ": "Harold Crayon",
// Табы/переводы строк как пробелы, сведены к одиночным.
"Harold\tand the": "Harold and the",
// Управляющий символ (BEL U+0007) удаляется.
"Harold\u0007": "Harold",
// BOM (U+FEFF) удаляется.
"\ufeffFargo": "Fargo",
// Уже чистое — без изменений.
"The Matrix": "The Matrix",
}
for in, want := range cases {
if got := sanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizePlan_FieldsCleanedSrcUntouched(t *testing.T) {
// src намеренно содержит кириллический двойник — sanitizePlan его не трогает.
dirtySrc := "Hаrold.mkv"
p := Plan{
Title: "Hаrold",
OriginalTitle: " spaced ",
ProviderHint: "clean\u200b",
Files: []PlanFile{{Src: dirtySrc, Role: RoleMain}},
}
sanitizePlan(&p, nil)
if p.Title != "Harold" {
t.Errorf("Title = %q, want %q", p.Title, "Harold")
}
if p.OriginalTitle != "spaced" {
t.Errorf("OriginalTitle = %q, want %q", p.OriginalTitle, "spaced")
}
if p.ProviderHint != "clean" {
t.Errorf("ProviderHint = %q, want %q", p.ProviderHint, "clean")
}
if p.Files[0].Src != dirtySrc {
t.Errorf("files[].src изменён санитайзингом: %q != %q", p.Files[0].Src, dirtySrc)
}
}
func TestSanitizePlan_LogsOnChange(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Hаrold"} // 'а' — кириллица, санитайзинг перепишет
sanitizePlan(&p, log)
out := buf.String()
if !strings.Contains(out, "recognition plan field sanitized") || !strings.Contains(out, "field=title") {
t.Errorf("ожидался Debug-лог о санитайзинге title, got: %q", out)
}
}
func TestSanitizePlan_NoLogWhenClean(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Harold", OriginalTitle: "The Matrix"}
sanitizePlan(&p, log)
if buf.Len() != 0 {
t.Errorf("для чистых полей лога быть не должно, got: %q", buf.String())
}
}
+8 -1
View File
@@ -3,6 +3,7 @@ package recognize
import (
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
@@ -12,7 +13,11 @@ import (
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
func parsePlan(raw string, in Input) (Plan, error) {
//
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
// sanitizePlan) ДО структурной валидации: так, например, title из одних
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil {
return Plan{}, fmt.Errorf("no JSON object in response")
@@ -29,6 +34,8 @@ func parsePlan(raw string, in Input) (Plan, error) {
}
}
sanitizePlan(&p, log)
if err := validateSchema(&p, in); err != nil {
return Plan{}, err
}
+2 -2
View File
@@ -60,7 +60,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
in := inputWith("film.mkv")
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
p, err := parsePlan(raw, in)
p, err := parsePlan(raw, in, testLogger())
if err != nil {
t.Fatalf("parsePlan: %v", err)
}
@@ -73,7 +73,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
in := inputWith("film.mkv")
raw := `{"type":"movie","title":"Film","extra_field":123,
"files":[{"src":"film.mkv","role":"main"}]}`
if _, err := parsePlan(raw, in); err != nil {
if _, err := parsePlan(raw, in, testLogger()); err != nil {
t.Fatalf("unknown field should be tolerated: %v", err)
}
}
+111 -1
View File
@@ -183,6 +183,11 @@ type Download struct {
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
// формате RFC 3339 (UTC, суффикс Z), как created_at.
SourceAddedAt sql.NullString `db:"source_added_at"`
// RetriedAt — время последнего ручного retry (RFC 3339 UTC, суффикс Z), NULL
// пока задачу не повторяли. Приподнимает базис отсчёта таймаутов, чтобы
// возврат в downloading не ронял задачу снова на ближайшем тике (см.
// state-reconciliation «Ручной повтор»).
RetriedAt sql.NullString `db:"retried_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
@@ -236,6 +241,19 @@ func Now() time.Time { return time.Now().UTC() }
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
// RetriedTime возвращает время последнего ручного retry (UTC) и ok=false, если
// задачу ещё не повторяли (retried_at NULL) или метку не разобрать.
func (d Download) RetriedTime() (time.Time, bool) {
if !d.RetriedAt.Valid {
return time.Time{}, false
}
t, err := ParseTime(d.RetriedAt.String)
if err != nil {
return time.Time{}, false
}
return t, true
}
// NullString строит sql.NullString: пустая строка → NULL.
func NullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
@@ -272,8 +290,18 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe
// Дедуп нашёл активного владельца по одному из хешей — остальные хеши
// norm принадлежат тому же торренту (гибридный magnet): дописываем
// недостающие, иначе второй хеш молча теряется и последующий приём по
// нему создал бы вторую активную задачу.
// нему создал бы вторую активную задачу. Дозапись — под тем же пер-хеш
// гардом владения, что и AddInfohashes: хеш, которым владеет ДРУГАЯ
// активная задача (split-identity/крафт-магнет), не дописываем, иначе
// две активные владели бы одним инфохэшем (нарушение инварианта).
for _, h := range norm {
owner, err := findActiveByInfohash(ctx, tx, []string{h}, existing.ID)
if err != nil {
return nil, fmt.Errorf("create download: %w", err)
}
if owner != nil {
continue // чужой активный владелец — не крадём хеш
}
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`,
existing.ID, h, HashKind(h), now); err != nil {
@@ -335,6 +363,57 @@ func (s *Store) GetTorrentData(ctx context.Context, downloadID string) ([]byte,
return data, nil
}
// UpgradeCatchedMagnetToTorrent апгрейдит пойманную magnet-задачу до torrent
// при дедупе входящих байтов `.torrent`: в одной write-транзакции сохраняет
// байты и меняет source_type magnet→torrent, но ТОЛЬКО пока задача в `catched`
// (воркер источник ещё не отдал в qBittorrent) и её source_type всё ещё
// `magnet`. Это целевое исключение из правила «при дедупе байты не сохраняем»:
// magnet на закрытом трекере без DHT метаданные не докачает и застрянет в
// metaDL, а поданный пользователем `.torrent` их несёт. Возвращает true, если
// апгрейд применён; (false, nil) — если задача не подходит (уже добавлена,
// отменена или не magnet) либо байты пусты (защитный no-op). Гард через
// RowsAffected UPDATE — та же ре-валидация состояния, что у PromoteCatched.
func (s *Store) UpgradeCatchedMagnetToTorrent(ctx context.Context, downloadID string, torrentBlob []byte) (bool, error) {
if len(torrentBlob) == 0 {
return false, nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return false, fmt.Errorf("upgrade %s to torrent: begin tx: %w", downloadID, err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `
UPDATE download
SET source_type = ?, updated_at = ?
WHERE id = ? AND source_type = ? AND state = ?`,
string(SourceTorrent), FormatTime(Now()), downloadID, string(SourceMagnet), string(StateCatched))
if err != nil {
return false, fmt.Errorf("upgrade %s to torrent: %w", downloadID, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("upgrade %s to torrent: %w", downloadID, err)
}
if n == 0 {
// Не catched-magnet (уже добавлена/отменена/torrent) — апгрейд не нужен.
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("upgrade %s to torrent: commit: %w", downloadID, err)
}
return false, nil
}
// У magnet-задачи блоба не было; OR REPLACE — страховка идемпотентности.
if _, err := tx.ExecContext(ctx,
`INSERT OR REPLACE INTO download_torrent (download_id, data) VALUES (?, ?)`,
downloadID, torrentBlob); err != nil {
return false, fmt.Errorf("upgrade %s to torrent: store blob: %w", downloadID, err)
}
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("upgrade %s to torrent: commit: %w", downloadID, err)
}
return true, nil
}
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
@@ -443,6 +522,22 @@ func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) er
return nil
}
// SetRetriedAt проставляет время ручного retry задачи (сброс базиса отсчёта
// таймаутов, см. state-reconciliation «Ручной повтор»). В отличие от
// SetSourceAddedAt перезаписывает значение: retry можно повторять, и базис
// должен смещаться на каждый.
func (s *Store) SetRetriedAt(ctx context.Context, id string, t time.Time) error {
res, err := s.DB.ExecContext(ctx,
`UPDATE download SET retried_at = ? WHERE id = ?`, FormatTime(t), id)
if err != nil {
return fmt.Errorf("set download %s retried_at: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("set download %s retried_at: not found", id)
}
return nil
}
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
// по нему хронологична).
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
@@ -624,6 +719,21 @@ WHERE id = ? AND state = ?`,
return nil
}
// SetDisplayName обновляет отображаемое имя загрузки постфактум — перелив
// канонического имени после распознавания (см. capability ingest). В отличие от
// PromoteCatched не двигает FSM и не завязан на состояние (гарда state нет):
// обновление имени валидно и в review, и в терминальных done/orphaned. Имя —
// косметика (заголовок в UI + ярлык раздачи), пути на диске не затрагивает.
func (s *Store) SetDisplayName(ctx context.Context, id, name string) error {
if _, err := s.DB.ExecContext(ctx, `
UPDATE download
SET display_name = ?, updated_at = ?
WHERE id = ?`, name, FormatTime(Now()), id); err != nil {
return fmt.Errorf("set display name %s: %w", id, err)
}
return nil
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
+73
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"errors"
"slices"
"strings"
"sync"
"testing"
@@ -36,6 +37,34 @@ func newCatched() *Download {
}
}
func TestSetDisplayNamePostHoc(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := newDownloading()
d.DisplayName = "Unknown"
if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil {
t.Fatalf("create: existing=%v err=%v", existing, err)
}
// Терминальное состояние — SetDisplayName не завязан на state (в отличие от
// PromoteCatched) и должен обновлять имя и на done.
forceState(t, st, d.ID, StateDone)
if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("GetDownload: %v", err)
}
if got.DisplayName != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q", got.DisplayName)
}
if got.State != StateDone {
t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State)
}
}
// catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) {
@@ -167,6 +196,50 @@ func TestFindActiveByInfohash(t *testing.T) {
}
}
// F1: дедуп-ветка CreateDownloadIfNoActive доносит недостающие хеши под пер-хеш
// гардом владения — хеш, которым владеет ДРУГАЯ активная задача (split-identity
// гибрида), красть не должна, иначе две активные владели бы одним инфохэшем.
func TestDedupTopUpDoesNotStealForeignHash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
v1 := hashN(1)
v2 := hashN(2)
// A владеет v1, B владеет v2 — две активные задачи одного гибрида (split).
idA := mustCreate(t, st, v1)
idB := mustCreate(t, st, v2)
// Приём гибрида {v1, v2} → дедуп на одну из активных; чужой хеш не крадём.
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if existing == nil {
t.Fatal("ожидался дедуп на активную задачу")
}
assertHashes(t, st, idA, v1) // A по-прежнему владеет только v1
assertHashes(t, st, idB, v2) // B по-прежнему владеет только v2
}
// assertHashes проверяет точный набор инфохэшей загрузки.
func assertHashes(t *testing.T, st *Store, id string, want ...string) {
t.Helper()
d, err := st.GetDownload(context.Background(), id)
if err != nil {
t.Fatalf("get %s: %v", id, err)
}
got := d.HashList()
if len(got) != len(want) {
t.Fatalf("%s hashes = %v, want %v", id, got, want)
}
for _, w := range want {
if !slices.Contains(got, w) {
t.Fatalf("%s hashes = %v, want содержит %s", id, got, w)
}
}
}
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что
// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и
+28
View File
@@ -225,6 +225,34 @@ func TestSetSourceAddedAtOnce(t *testing.T) {
}
}
func TestSetRetriedAtOverwrites(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id := mkDownload(t, st, 1, StateStuck, "x")
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
if err := st.SetRetriedAt(ctx, id, first); err != nil {
t.Fatal(err)
}
// В отличие от source_added_at — повторный retry перезаписывает базис.
if err := st.SetRetriedAt(ctx, id, second); err != nil {
t.Fatal(err)
}
d, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if !d.RetriedAt.Valid || d.RetriedAt.String != FormatTime(second) {
t.Fatalf("retried_at = %q, want %q (последний retry)", d.RetriedAt.String, FormatTime(second))
}
got, ok := d.RetriedTime()
if !ok || !got.Equal(second) {
t.Fatalf("RetriedTime() = %v, %v; want %v, true", got, ok, second)
}
}
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
@@ -0,0 +1,11 @@
-- +goose Up
-- Время последнего ручного retry задачи (RFC 3339 UTC, суффикс Z). Приподнимает
-- базис отсчёта таймаутов (magnet_timeout/stuck_after): после retry отсчёт идёт
-- от max(базис_добавления или last_activity, retried_at), чтобы возврат в
-- downloading не ронял задачу снова на ближайшем тике (см. state-reconciliation
-- «Ручной повтор зависшей/упавшей загрузки»). Хранится в БД, а не в памяти,
-- чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
ALTER TABLE download ADD COLUMN retried_at TEXT;
-- +goose Down
ALTER TABLE download DROP COLUMN retried_at;
+26
View File
@@ -293,6 +293,32 @@ func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[str
return out, nil
}
// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок
// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот
// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости
// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их
// существование на диске и наследует базу имени. Пустой provider/id → пусто (без
// подтверждённого матча якоря нет). Проверка живости папки на диске —
// ответственность вызывающего (статус ссылки не гарантирует наличие папки).
func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
if provider == "" || providerID == "" {
return nil, nil
}
const q = `
SELECT fl.dst_path
FROM file_link fl
JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1
WHERE fl.status IN ('linked', 'copied', 'exists')
AND fl.download_id != ?
AND r.provider = ?
AND r.provider_id = ?`
var out []string
if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil {
return nil, fmt.Errorf("live title folders: %w", err)
}
return out, nil
}
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
if _, err := s.DB.ExecContext(ctx,
+60
View File
@@ -289,6 +289,66 @@ func TestCandidates_Lifecycle(t *testing.T) {
}
}
func TestLiveTitleFolders(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
// anchor и other сматчены на один (tvdb, 269613); alien — на другой матч.
anchor := mustCreate(t, st, "1100ccddeeff00112233445566778899aabbccdd")
other := mustCreate(t, st, "2200ccddeeff00112233445566778899aabbccdd")
alien := mustCreate(t, st, "3300ccddeeff00112233445566778899aabbccdd")
seedMatch := func(dl, provider, id string) {
if _, err := st.CreateRecognition(ctx, &Recognition{
DownloadID: dl, Provider: NullString(provider), ProviderID: NullString(id),
}, nil); err != nil {
t.Fatalf("seed recognition: %v", err)
}
}
seedMatch(anchor, "tvdb", "269613")
seedMatch(other, "tvdb", "269613")
seedMatch(alien, "tvdb", "999")
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: anchor, ApplyBatchID: "a", SrcPath: "/d/a.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 01/e.mkv", Kind: "video", Status: "linked"},
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv", Kind: "video", Status: "exists"},
// superseded/collision у other — не живые, не должны попасть.
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/x.mkv", DstPath: "/s/Old/e.mkv", Kind: "video", Status: "superseded"},
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/y.mkv", DstPath: "/s/Coll/e.mkv", Kind: "video", Status: "collision"},
// alien — другой матч, мимо.
{DownloadID: alien, ApplyBatchID: "z", SrcPath: "/d/z.mkv", DstPath: "/s/Other/e.mkv", Kind: "video", Status: "linked"},
}); err != nil {
t.Fatalf("create links: %v", err)
}
// Из позиции anchor: видим только живую ссылку other (себя исключаем).
got, err := st.LiveTitleFolders(ctx, "tvdb", "269613", anchor)
if err != nil {
t.Fatalf("live title folders: %v", err)
}
if len(got) != 1 || got[0] != "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv" {
t.Errorf("got %v, want только живую ссылку other", got)
}
// Без исключения — обе живые ссылки матча (anchor + other), но не superseded/
// collision/alien.
got, err = st.LiveTitleFolders(ctx, "tvdb", "269613", "")
if err != nil {
t.Fatalf("live title folders: %v", err)
}
if len(got) != 2 {
t.Errorf("got %v, want 2 живые ссылки", got)
}
// Пустой provider/id → пусто (нет подтверждённого матча).
if got, _ := st.LiveTitleFolders(ctx, "", "269613", ""); got != nil {
t.Errorf("пустой provider → пусто, got %v", got)
}
if got, _ := st.LiveTitleFolders(ctx, "tvdb", "", ""); got != nil {
t.Errorf("пустой id → пусто, got %v", got)
}
}
func TestGetCandidate_None(t *testing.T) {
st := newTestStore(t)
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
+107
View File
@@ -65,3 +65,110 @@ func TestTorrentBlobMissing(t *testing.T) {
t.Errorf("ожидался ErrNotFound, got %v", err)
}
}
// F6: апгрейд пойманного magnet до torrent сохраняет байты и меняет source_type.
func TestUpgradeCatchedMagnetToTorrent(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + hashN(1), State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil {
t.Fatalf("create: %v", err)
}
blob := []byte("d8:announce…real torrent")
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, blob)
if err != nil || !upgraded {
t.Fatalf("upgrade: upgraded=%v err=%v", upgraded, err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SourceType != SourceTorrent {
t.Errorf("source_type = %q, want torrent", got.SourceType)
}
data, err := st.GetTorrentData(ctx, d.ID)
if err != nil {
t.Fatalf("get torrent data: %v", err)
}
if string(data) != string(blob) {
t.Errorf("blob = %q, want %q", data, blob)
}
}
// Апгрейд применим только в catched: уже добавленный (downloading) magnet не
// трогаем — его судьба решается retry/сверкой, а не приёмом.
func TestUpgradeSkippedWhenNotCatched(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "m", State: StateDownloading}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(2)}, nil); err != nil {
t.Fatalf("create: %v", err)
}
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, []byte("bytes"))
if err != nil {
t.Fatalf("upgrade: %v", err)
}
if upgraded {
t.Error("downloading-magnet апгрейдить не должны")
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SourceType != SourceMagnet {
t.Errorf("source_type сменился на %q, а не должен", got.SourceType)
}
if _, err := st.GetTorrentData(ctx, d.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("байты не должны сохраняться, got err=%v", err)
}
}
// Не-magnet источник апгрейд не трогает (гард source_type=magnet), байты не
// перезаписывает.
func TestUpgradeSkippedForTorrentSource(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := &Download{SourceType: SourceTorrent, SourceRef: "Rel", State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(3)}, []byte("orig")); err != nil {
t.Fatalf("create: %v", err)
}
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, []byte("new"))
if err != nil {
t.Fatalf("upgrade: %v", err)
}
if upgraded {
t.Error("torrent-источник апгрейдить не нужно")
}
data, err := st.GetTorrentData(ctx, d.ID)
if err != nil {
t.Fatalf("get torrent data: %v", err)
}
if string(data) != "orig" {
t.Errorf("байты перезаписаны: %q", data)
}
}
// Пустые байты — защитный no-op.
func TestUpgradeEmptyBlobNoOp(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := &Download{SourceType: SourceMagnet, SourceRef: "m", State: StateCatched}
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(4)}, nil); err != nil {
t.Fatalf("create: %v", err)
}
upgraded, err := st.UpgradeCatchedMagnetToTorrent(ctx, d.ID, nil)
if err != nil || upgraded {
t.Fatalf("пустой блоб: upgraded=%v err=%v", upgraded, err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SourceType != SourceMagnet {
t.Errorf("source_type сменился на %q при пустом блобе", got.SourceType)
}
}
+61 -19
View File
@@ -7,7 +7,6 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -16,6 +15,8 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logging"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -45,6 +46,7 @@ type Reviewer interface {
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -105,6 +107,12 @@ func (b *Bot) Run(ctx context.Context) {
return
case u, ok := <-updates:
if !ok {
// Канал закрыт не по нашей отмене (ctx ещё жив) — библиотека
// прекратила приём обновлений: иначе бот молча перестал бы
// реагировать без следа в логах.
if ctx.Err() == nil {
b.log.Error("telegram updates channel closed unexpectedly")
}
return
}
b.handleUpdate(ctx, u)
@@ -146,6 +154,8 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
// Ждём подсказку для перераспознавания?
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
if err := b.reviewer.Refine(ctx, id, text); err != nil {
// Ошибку логирует доменная граница (worker.Refine); транспорт лишь
// показывает пользователю (logging.md: «транспорты не логируют»).
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
@@ -160,6 +170,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
source, context, ok := ParseMessage(text)
if !ok {
// Невалидный ввод — норма (пользователь получит отказ): DEBUG, чтобы
// при разборе «почему не приняло» отказ был виден в логах.
b.log.Debug("telegram source not recognized", "chat_id", m.Chat.ID)
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
return
}
@@ -171,10 +184,12 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
doc := m.Document
if !isTorrentDoc(doc) {
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "not-torrent", "mime", doc.MimeType)
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
return
}
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "too-large", "size", doc.FileSize)
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
return
}
@@ -191,16 +206,6 @@ func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
})
}
// 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 {
@@ -217,20 +222,22 @@ func isTorrentDoc(doc *tgbotapi.Document) bool {
//
// ВАЖНО: прямой URL файла Telegram содержит токен бота
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
// URL в текст — их нельзя возвращать/логировать как есть. logging.SanitizeErr
// оставляет только первопричину без URL, чтобы токен не утёк в логи (см.
// logging.md). GetFileDirectURL тоже ходит в Bot API (…/bot<TOKEN>/getFile) —
// его ошибку санитизируем так же.
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)
return nil, fmt.Errorf("file url: %w", logging.SanitizeErr(err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
return nil, fmt.Errorf("telegram file request: %w", logging.SanitizeErr(err))
}
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
return nil, fmt.Errorf("telegram file GET: %w", logging.SanitizeErr(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
@@ -305,6 +312,20 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "retry":
err = b.reviewer.Retry(ctx, id)
note = "Повторяю…"
case "delete":
// Первый шаг: показываем подтверждение (необратимое действие — снос
// раздачи+файлов и хардлинков). Домен не трогаем, меняем клавиатуру.
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.deleteConfirmKeyboard(id))
return
case "delete_cancel":
// Отмена подтверждения — возвращаем обычную карточку.
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "delete_confirm":
err = b.reviewer.Delete(ctx, id)
note = "Удаляю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
@@ -325,6 +346,16 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
return
}
if errors.Is(err, layout.ErrCollision) {
// Коллизия цели: задача штатно ушла в review с причиной — показываем
// конкретно и обновляем карточку (в ней теперь причина коллизии).
b.answer(cq.ID, "Коллизия цели")
b.send(chatID, opErr("Целевой файл уже существует — задача отправлена в ревью", id), nil)
b.refreshCard(ctx, chatID, msgID, id)
return
}
// Ошибку логирует доменная граница (соответствующая команда worker);
// транспорт лишь переводит её в ответ пользователю (logging.md).
b.answer(cq.ID, "Ошибка")
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
return
@@ -348,7 +379,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id strin
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
}
if _, err := b.api.Send(edit); err != nil {
b.log.Warn("telegram edit card failed", "download_id", id, "error", err)
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
}
}
@@ -387,13 +418,24 @@ func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup)
msg.ReplyMarkup = *kb
}
if _, err := b.api.Send(msg); err != nil {
b.log.Warn("telegram send failed", "chat_id", chatID, "error", err)
b.log.Warn("telegram send failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
}
}
func (b *Bot) answer(callbackID, text string) {
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
b.log.Warn("telegram answer callback failed", "error", err)
b.log.Warn("telegram answer callback failed", "error", logging.SanitizeErr(err))
}
}
// editMarkup подменяет только клавиатуру сообщения на месте (текст не трогаем).
// Используется для шага подтверждения удаления.
func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMarkup) {
if kb == nil {
return
}
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", logging.SanitizeErr(err))
}
}
+5
View File
@@ -69,6 +69,7 @@ type fakeReviewer struct {
deferred []string
canceled []string
retried []string
deleted []string
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -104,6 +105,10 @@ func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
+1 -1
View File
@@ -63,7 +63,7 @@ func TestBot_DocumentByExtension(t *testing.T) {
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
// что stripURL его убрал.
// что logging.SanitizeErr его убрал.
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
+46
View File
@@ -0,0 +1,46 @@
package tgbot
import (
"fmt"
"log/slog"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// SetLibraryLogger направляет внутренние логи клиента tgbotapi в наш slog.
//
// Зачем: библиотека логирует сбои long-poll `getUpdates` через собственный
// (stdlib `log`) логгер — МИМО slog. Из-за этого сетевые/API-ошибки поллинга не
// попадали в структурированные JSON-логи: входящее сообщение молча не
// подхватывалось, а в логах — пусто (см. logging.md). После вызова такие сбои
// видны как `telegram library` (WARN).
//
// Токен вырезается из текста: строка ошибки транспорта — `*url.Error` с URL вида
// `…/bot<TOKEN>/getUpdates`, писать её как есть нельзя (утечка секрета в логи).
// Замена по подстроке страхует и от прочих мест, где токен мог бы просочиться.
//
// Логгер в tgbotapi — глобальный на пакет; вызывать один раз при старте.
func SetLibraryLogger(log *slog.Logger, token string) error {
return tgbotapi.SetLogger(libLogger{log: log, token: token})
}
type libLogger struct {
log *slog.Logger
token string
}
func (l libLogger) Println(v ...any) { l.emit(fmt.Sprintln(v...)) }
func (l libLogger) Printf(format string, v ...any) { l.emit(fmt.Sprintf(format, v...)) }
func (l libLogger) emit(msg string) {
msg = strings.TrimSpace(msg)
if l.token != "" {
msg = strings.ReplaceAll(msg, l.token, "***")
}
// Сбои поллинга транзиентны (библиотека повторяет через 3 с) — WARN
// («retry внешнего вызова»); устойчивый сбой станет потоком WARN — сигнал
// разбираться, но не ERROR на каждый повтор.
l.log.Warn("telegram library", "detail", msg)
}
+30 -3
View File
@@ -24,15 +24,19 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateLinking:
return "⏳ Раскладываю #" + id + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
return b.renderDone(rd), b.deletableKeyboard(id)
default:
text := fmt.Sprintf("Задача #%s — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
// failed/stuck — даём кнопку повтора; остальное только «в вебе».
if state == store.StateFailed || state == store.StateStuck {
switch state {
case store.StateFailed, store.StateStuck:
// failed/stuck — даём кнопку повтора.
return text, b.retryKeyboard(id)
case store.StateOrphaned, store.StateTargetMissing:
// Рассинхрон — можно подчистить остатки полным удалением.
return text, b.deletableKeyboard(id)
}
return text, b.webOnly(id)
}
@@ -164,6 +168,29 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
var row []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
))
return &kb
}
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
+7
View File
@@ -13,3 +13,10 @@ var ErrConflict = errors.New("conflict")
// от ErrConflict (тоже 409), потому что причина actionable — «дождись докачки»
// — и транспорт показывает её конкретным текстом, а не генериком конфликта.
var ErrNotReady = errors.New("source not ready")
// ErrInvalidInput — команда отклонена из-за некорректного пользовательского
// ввода (пустая подсказка, неизвестный тип/провайдер, пустой id, кандидат не из
// текущей рекогниции). Это промах пользователя, а не сбой сервера: транспорт
// матчит его через errors.Is и отвечает 400, а логирующая граница домена пишет
// DEBUG (адресат — пользователь, он уже получил ответ на поверхности).
var ErrInvalidInput = errors.New("invalid input")
+3 -1
View File
@@ -208,7 +208,9 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
logctx.From(ctx).Warn("recovery activate failed", "error", err)
return
}
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
// Восстановление из failed/stuck — тоже переход состояния: единый msg
// `state transition` (from/to), qbit_state — отличительная деталь авто-воскрешения.
logctx.From(ctx).Info("state transition", "from", d.State, "to", want, "qbit_state", t.State)
}
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
+8 -4
View File
@@ -151,13 +151,17 @@ func TestReconcileDebounce(t *testing.T) {
}
func TestReconcileSkipsActiveStates(t *testing.T) {
// downloading сверкой не трогаем, даже если раздачи нет в qBittorrent.
f := newReconcileFixture(t, store.StateDownloading, false, true)
// Матрица сверки (reconcileDesync) не трогает активные состояния, даже если
// раздачи нет в qBittorrent. Берём review (не downloading): активный цикл
// поллинга листает только downloading, поэтому review остаётся нетронутым
// — пропажу источника у downloading отдельно ведёт правило source_gone
// (см. recovery_test: TestSourceGone*).
f := newReconcileFixture(t, store.StateReview, false, true)
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
if got := f.st.downloads["1"].State; got != store.StateReview {
t.Errorf("state = %q, want review (сверка не трогает активные)", got)
}
}
+226
View File
@@ -168,3 +168,229 @@ func TestRetryReattachesNoReadd(t *testing.T) {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
}
}
// addedLongAgo — added_on «5 часов назад» относительно now теста (10:00:00 UTC):
// торрент давно в qBittorrent (возраст сам по себе большой).
var addedLongAgo = time.Date(2026, 6, 14, 5, 0, 0, 0, time.UTC).Unix()
// lastActivityLongAgo — last_activity «2 часа назад»: данные давно не двигались.
var lastActivityLongAgo = time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC).Unix()
// MAJOR-1: retry живого, но давно добавленного и простаивающего stalledDL-торрента
// сбрасывает базис таймаута (retried_at), поэтому СЛЕДУЮЩИЙ тик не роняет задачу
// снова в stuck. Без сброса базиса stallDuration=nowlast_activity (2ч) > StuckAfter
// (1ч) → задача мгновенно вернулась бы в stuck (регрессия, которую прячет
// TestRetryReattachesNoReadd, ставящий added_on/last_activity «минуту назад»).
func TestRetryResetsTimeoutBasis(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
// Торрент жив, но давно добавлен (added_on 5ч) и давно простаивает
// (last_activity 2ч) — по старой мере «возраст» он мгновенно снова stuck.
qb := &fakeQbt{torrents: []qbt.Torrent{{
Hash: ih, State: "stalledDL",
AddedOn: addedLongAgo, LastActivity: lastActivityLongAgo,
}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.added) != 0 {
t.Errorf("живой здоровый торрент не должен добавляться повторно, got %d Add", len(qb.added))
}
if !st.downloads["1"].RetriedAt.Valid {
t.Error("retry должен проставить retried_at (сброс базиса)")
}
// Ключевая проверка MAJOR-1: следующий тик поллинга.
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("после retry задача не должна снова падать в stuck на ближайшем тике, got %q", got)
}
}
// MAJOR-2: stuck_after мерит ДЛИТЕЛЬНОСТЬ ПРОСТОЯ (nowlast_activity), а не возраст
// торрента. Долго качавшийся торрент (added_on 5ч назад) с недавним движением
// данных (last_activity 30с назад), на миг зашедший в stalledDL, НЕ уходит в stuck.
func TestStallMeasuredFromLastActivity(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
lastActivityRecent := time.Date(2026, 6, 14, 9, 59, 30, 0, time.UTC).Unix() // 30с назад
st := oneDownloading(ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{
Hash: ih, State: "stalledDL",
AddedOn: addedLongAgo, LastActivity: lastActivityRecent,
}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("свежая активность (30с) — не stuck несмотря на возраст 5ч, got %q", got)
}
// Контроль: тот же торрент, но данные давно не двигались (last_activity 2ч) —
// простой превысил StuckAfter (1ч) → stuck.
qb.torrents[0].LastActivity = lastActivityLongAgo
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateStuck {
t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got)
}
}
// Оборонительный клэмп: last_activity из будущего (перекос часов или sentinel
// «никогда не был активен») трактуется как непригодное значение. Иначе простой
// вышел бы отрицательным и реально застрявший торрент никогда не пометился бы
// stuck. Значение игнорируется, простой считается от базиса добавления (5ч).
func TestStallIgnoresFutureLastActivity(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
lastActivityFuture := time.Date(2026, 6, 14, 11, 0, 0, 0, time.UTC).Unix() // 1ч в будущем
st := oneDownloading(ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{
Hash: ih, State: "stalledDL",
AddedOn: addedLongAgo, LastActivity: lastActivityFuture,
}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if got := st.downloads["1"].State; got != store.StateStuck {
t.Errorf("last_activity из будущего игнорируется (фолбэк на возраст 5ч > stuck_after 1ч → stuck), got %q", got)
}
}
// --- MAJOR-3: пропажа источника у активной (downloading) загрузки ---
// Источник активной загрузки устойчиво пропал из qBittorrent → задача уходит в
// failed(source_gone) с уведомлением, а не остаётся вечным зомби.
func TestSourceGoneMarksFailed(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
w := newTestWorker(st, &fakeQbt{torrents: nil}) // раздачи нет
w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
d := st.downloads["1"]
if d.State != store.StateFailed {
t.Fatalf("state = %q, want failed", d.State)
}
if got := d.ErrorCode.String; got != errCodeSourceGone {
t.Errorf("error_code = %q, want %q", got, errCodeSourceGone)
}
if e := waitNotify(t, n); e.ev != EventFailed {
t.Errorf("пинг: ev=%v, want failed", e.ev)
}
}
// Кратковременная пропажа (меньше порога) не роняет задачу — дебаунс терпит
// транзиентную недоступность qBittorrent (рестарт демона).
func TestSourceGoneDebounced(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
w := newTestWorker(st, &fakeQbt{torrents: nil})
w.cfg.SourceMissingThreshold = 3
for i := 1; i <= 2; i++ {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Fatalf("tick %d: state = %q, want downloading (до порога)", i, got)
}
if got := st.downloads["1"].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := st.downloads["1"].State; got != store.StateFailed {
t.Fatalf("tick 3: state = %q, want failed (порог достигнут)", got)
}
if got := st.downloads["1"].ErrorCode.String; got != errCodeSourceGone {
t.Errorf("error_code = %q, want %q", got, errCodeSourceGone)
}
}
// Возврат раздачи до порога сбрасывает счётчик промахов и задача ведётся
// обычной сверкой (не падает).
func TestSourceGoneResetOnReturn(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
qb := &fakeQbt{torrents: nil}
w := newTestWorker(st, qb)
w.cfg.SourceMissingThreshold = 3
if err := w.Poll(context.Background()); err != nil { // промах 1
t.Fatalf("Poll miss: %v", err)
}
if got := st.downloads["1"].SourceMissCount; got != 1 {
t.Fatalf("miss = %d, want 1", got)
}
// Раздача вернулась (свежий metaDL — не падает по таймауту).
qb.torrents = []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll return: %v", err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (источник вернулся)", got)
}
if got := st.downloads["1"].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
// source_gone восстановлению сверкой не подлежит: даже если раздача снова
// появилась и продвинулась, reconcileRecovery её не воскрешает — нужен ручной
// Retry.
func TestSourceGoneNotAutoRecovered(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeOld)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads["1"].State; got != store.StateFailed {
t.Errorf("state = %q, want failed (source_gone сверкой не воскрешается)", got)
}
}
// Retry задачи source_gone сбрасывает source_miss_count, чтобы вернувшаяся в
// downloading задача получила полное грейс-окно, а не упала снова на ближайшем
// тике, если переотданная раздача ещё не видна в qBittorrent.
func TestRetryResetsSourceMissCount(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeSourceGone, ih, timeRecent)
st.downloads["1"].SourceMissCount = 3 // задача упала по порогу
qb := &fakeQbt{torrents: nil} // раздачи всё ещё нет (Add вслепую)
w := newTestWorker(st, qb)
w.cfg.SourceMissingThreshold = 3
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if got := st.downloads["1"].SourceMissCount; got != 0 {
t.Fatalf("после retry miss = %d, want 0 (сброс)", got)
}
if len(qb.added) != 1 {
t.Errorf("retry без живой раздачи должен переотдать источник, got %d Add", len(qb.added))
}
// Грейс-окно: раздача ещё не видна — задача НЕ падает на ближайшем тике
// (miss стал бы 1 < 3, а не 4 при несброшенном счётчике).
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll after retry: %v", err)
}
if got := st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (грейс-окно не съедено)", got)
}
if got := st.downloads["1"].SourceMissCount; got != 1 {
t.Errorf("miss = %d, want 1 (один промах после сброса)", got)
}
}
+326 -67
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strconv"
"strings"
@@ -14,6 +15,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -132,10 +134,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
planJSON = []byte("{}")
}
provider, providerID, tag := "none", "", ""
provider, providerID := "none", ""
if res.Match != nil {
provider, providerID = res.Match.Provider, res.Match.ProviderID
tag = providerTag(res.Match.Provider, res.Match.ProviderID)
}
rec := &store.Recognition{
@@ -194,8 +195,16 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
w.transition(lctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
// Claim перехода в linking должен закоммититься до хардлинков (MINOR-7):
// при провале записи не линкуем — задача остаётся в recognizing, и
// поллинг-цикл (recognizePending) повторит распознавание/авто-раскладку.
// Правило сходимости папки (и уход в review при рассинхроне) — внутри
// linkPlan, из состояния linking.
if err := w.transitionErr(lctx, *d, store.StateLinking, "", ""); err != nil {
logctx.From(lctx).Warn("auto-apply claim failed, left for recognizing", "error", err)
return
}
if err := w.linkPlan(lctx, d, plan, provider, providerID, savePath); err != nil {
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
}
return
@@ -217,7 +226,8 @@ func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]strin
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id string) error {
func (w *Worker) Apply(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "apply", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -233,7 +243,7 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
}
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
plan, tag, err := w.effectivePlan(ctx, id)
plan, prov, pid, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
@@ -253,20 +263,41 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
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 {
// Claim перехода в linking ОБЯЗАН закоммититься до создания хардлинков: при
// провале записи не линкуем (иначе ссылки лягут при задаче в review, а
// финальный linking→done граф отклонит — MINOR-7). Задача остаётся в
// review/deferred, повтор безопасен. Правило сходимости папки (и уход в
// review при рассинхроне) — уже внутри linkPlan, из состояния linking.
if err := w.transitionErr(ctx, *d, store.StateLinking, "", ""); err != nil {
return fmt.Errorf("apply: %w", err)
}
if err := w.linkPlan(ctx, d, plan, prov, pid, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
return fmt.Errorf("apply: %w", err)
}
return nil
}
// linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и
// двигает задачу: done при успехе, review при коллизии/невалидном плане,
// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu.
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error {
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
// двигает задачу: done при успехе, review при коллизии/невалидном плане/
// рассинхроне папок, failed при иной ошибке ФС. Идемпотентен (повтор доводит
// начатое). Под mu; вызывается уже в состоянии linking (claim закоммичен) —
// поэтому уход в review здесь легален (linking→review), как для коллизии.
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, provider, providerID, savePath string) error {
// Правило сходимости папки: при подтверждённом матче база наследуется от
// живого якоря; рассинхрон (несколько разных живых папок) → review.
folderBase, desync, err := w.resolveFolderBase(ctx, d.ID, provider, providerID, layout.MediaType(plan.Type))
if err != nil {
w.transition(ctx, *d, store.StateReview, "build", err.Error())
w.transition(ctx, *d, store.StateReview, reasonResolve, err.Error())
return fmt.Errorf("link plan: %w", err)
}
if desync {
w.transition(ctx, *d, store.StateReview, reasonTitleFolderDesync, "несколько живых папок тайтла с одним матчем")
return fmt.Errorf("рассинхрон папок тайтла: %w", ErrConflict)
}
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
if err != nil {
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error())
return fmt.Errorf("build links: %w", err)
}
@@ -288,6 +319,12 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
}
if len(fl) > 0 {
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
// Хардлинки уже на диске, но их учёт не записан (транзиентная ошибка
// SQLite). НЕ оставляем задачу в linking (осиротела бы до sweep, а
// файлы висели бы без file_link — MAJOR-4): уводим в review с
// причиной. Повторный Apply идемпотентен — Apply вернёт StatusExists
// на уже созданных ссылках и допишет учёт.
w.transition(ctx, *d, store.StateReview, reasonPersist, err.Error())
return fmt.Errorf("persist links: %w", err)
}
// Инвариант «один целевой путь — один владелец»: забираем владение
@@ -310,7 +347,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
if applyErr != nil {
if errors.Is(applyErr, layout.ErrCollision) {
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
w.transition(ctx, *d, store.StateReview, reasonCollision, applyErr.Error())
return applyErr
}
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
@@ -327,7 +364,8 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
func (w *Worker) Relink(ctx context.Context, id string) error {
func (w *Worker) Relink(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "relink", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -357,14 +395,15 @@ func (w *Worker) Relink(ctx context.Context, id string) error {
}
return fmt.Errorf("relink: %w", err)
}
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
logctx.From(ctx).Info("state transition", "from", d.State, "to", store.StateRecognizing)
return nil
}
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "rerecognize", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -382,10 +421,11 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error) {
defer func() { w.logCmd(ctx, "refine", id, err) }()
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
return fmt.Errorf("refine: empty hint: %w", ErrInvalidInput)
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -408,9 +448,10 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
defer func() { w.logCmd(ctx, "set_type", id, err) }()
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
return fmt.Errorf("set type: invalid type %q: %w", mediaType, ErrInvalidInput)
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -439,10 +480,11 @@ func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
defer func() { w.logCmd(ctx, "ignore_file", id, err) }()
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
return fmt.Errorf("ignore: empty path: %w", ErrInvalidInput)
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -468,7 +510,8 @@ func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id string) error {
func (w *Worker) Defer(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "defer", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -477,7 +520,7 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
return fmt.Errorf("defer: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "")
@@ -488,7 +531,8 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id string) error {
func (w *Worker) Undo(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "undo", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -513,23 +557,13 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
return fmt.Errorf("undo: %w", err)
}
if batch == "" {
return fmt.Errorf("undo: nothing to revert")
return fmt.Errorf("undo: nothing to revert: %w", ErrConflict)
}
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil {
return fmt.Errorf("undo: %w", err)
}
// Снимаем только реально разложенные нами ссылки. superseded-строки —
// путь забрала другая загрузка (см. state-reconciliation, владение
// путём); файл по нему теперь её хардлинк, трогать его нельзя.
links := make([]layout.Link, 0, len(rows))
for _, r := range rows {
if !isLaidOut(r.Status) {
continue
}
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
}
n, err := w.layouter.Undo(ctx, links)
n, err := w.layouter.Undo(ctx, laidOutLinks(rows))
if err != nil {
return fmt.Errorf("undo: %w", err)
}
@@ -541,6 +575,91 @@ func (w *Worker) Undo(ctx context.Context, id string) error {
return nil
}
// laidOutLinks отбирает реально разложенные нами ссылки батча и маппит их в
// layout.Link. superseded-строки пропускаем: путь забрала другая загрузка (см.
// state-reconciliation, владение путём), файл по нему — её хардлинк, трогать
// нельзя. Общий для Undo (снимает с гардом) и Delete (снимает без гарда).
func laidOutLinks(rows []store.FileLink) []layout.Link {
links := make([]layout.Link, 0, len(rows))
for _, r := range rows {
if !isLaidOut(r.Status) {
continue
}
links = append(links, layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)})
}
return links
}
// Delete — пользовательское удаление загрузки: снять наши библиотечные ссылки
// (гард последней копии ВЫКЛЮЧЕН, в отличие от Undo) И снести раздачу с файлами
// из qBittorrent, переведя задачу в терминальный deleted. Осознанный выход за
// инвариант «источник неприкосновенен» — вызывается только после подтверждения
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
// его отсутствие трактуем как уже снятую сторону).
func (w *Worker) Delete(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "delete", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
return fmt.Errorf("delete: layouter not configured")
}
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
switch d.State {
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
default:
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
// (а) Снять цель — наши библиотечные ссылки последнего батча БЕЗ гарда
// последней копии. superseded пропускаем: путь забрала другая загрузка (см.
// state-reconciliation, владение путём), её хардлинк трогать нельзя. В
// target_missing / после ручного удаления живых ссылок нет — снятие
// идемпотентно.
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
removed := 0
if batch != "" {
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil {
return fmt.Errorf("delete: %w", err)
}
removed, err = w.layouter.Remove(ctx, laidOutLinks(rows))
if err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := w.store.DeleteFileLinksByBatch(ctx, batch); err != nil {
return fmt.Errorf("delete: %w", err)
}
}
// (б) Снять источник — раздачу с файлами из qBittorrent. Идемпотентно:
// отсутствие раздачи (orphaned) не ошибка — qBit не найдёт хеш и ответит OK.
// Реальную ошибку API пробрасываем и в deleted НЕ уводим: не заявляем
// освобождённое место, которого не произошло (цель уже снята → сверка
// приведёт запись к target_missing; повторный delete идемпотентно дожмёт).
if hashes := d.HashList(); len(hashes) > 0 {
if err := w.qbt.Delete(ctx, hashes, true); err != nil {
return fmt.Errorf("delete: qbittorrent: %w", err)
}
}
// (в) Терминальный deleted с пользовательским маркером инициатора
// (отличает от reconcile-deleted, который кладёт "reconcile").
w.transition(ctx, *d, store.StateDeleted, "user_delete", "удалено пользователем")
// Запись физического эффекта (снятые ссылки) сверх перехода: from/code уже в
// каноническом `state transition` выше — здесь только отличительное поле.
logctx.From(ctx).Info("download deleted by user", "removed_links", removed)
return nil
}
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
d, err := w.store.GetDownload(ctx, id)
@@ -558,7 +677,8 @@ func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (err error) {
defer func() { w.logCmd(ctx, "choose_candidate", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -575,7 +695,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition: %w", candidateID, ErrInvalidInput)
}
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
}
@@ -583,16 +703,17 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "add_manual_source", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
}
if providerID == "" {
return fmt.Errorf("add source: empty id")
return fmt.Errorf("add source: empty id: %w", ErrInvalidInput)
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -674,20 +795,28 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
// выбор кандидата: логируем и продолжаем.
if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
Warn("display name refresh after candidate choice failed", "error", err)
}
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "set_provider_id", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider)
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb): %w", provider, ErrInvalidInput)
}
if providerID == "" {
return fmt.Errorf("set provider: empty id")
return fmt.Errorf("set provider: empty id: %w", ErrInvalidInput)
}
w.mu.Lock()
defer w.mu.Unlock()
@@ -710,7 +839,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
// пины названия/года (источник — распознавание нейронкой).
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "clear_provider", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -825,17 +955,25 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
rd.Plan = applyOverrides(rawPlan, overrides)
// Превью активного источника строим по относительным путям с
// provider-тегом; ошибку логируем на Debug — покажем без превью.
// База наследуется тем же правилом сходимости, что и применение
// (превью=применение); рассинхрон в превью не переводит в review.
if w.layouter != nil {
tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
base, _, berr := w.resolveFolderBase(ctx, id, prov, pid, layout.MediaType(rd.Plan.Type))
if berr != nil {
log.Debug("review data resolve folder base failed", "error", berr)
}
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag, base)); lerr == nil {
rd.Preview = links
} else {
log.Debug("review data build preview failed", "error", lerr)
// Видимая деградация: без превью на экране ревью пропадает
// кнопка «Применить» — не рядовой Debug, а WARN.
log.Warn("review data build preview failed", "error", lerr)
}
}
// Единый список источников: нейронка + кандидаты, каждый с
// эфемерным превью из сырого плана (без записи overrides).
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
rd.Sources = w.buildSources(ctx, id, rawPlan, overrides, prov, pid, rd.Candidates)
}
}
return rd, nil
@@ -844,10 +982,10 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
// buildSources собирает единый список источников: нейронка (первой) +
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
// источник.
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
neutral := prov == "" || prov == "none"
out := make([]SourceOption, 0, len(cands)+1)
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
out = append(out, w.sourceOption(ctx, downloadID, SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
seen := map[string]bool{}
for _, c := range cands {
key := c.Provider + ":" + c.ProviderID
@@ -864,7 +1002,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
year = int(c.Year.Int64)
}
active := !neutral && c.Provider == prov && c.ProviderID == pid
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
out = append(out, w.sourceOption(ctx, downloadID, SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
}
return out
}
@@ -872,7 +1010,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
// sourceOption строит один источник: накладывает его пины на неисточниковые
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
opt := SourceOption{
Kind: kind,
@@ -887,7 +1025,10 @@ func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[
Plan: eff,
}
if w.layouter != nil {
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
// Превью источника наследует базу тем же правилом сходимости, что и
// применение (превью=применение); рассинхрон в превью не переводит в review.
folderBase, _, _ := w.resolveFolderBase(ctx, downloadID, provider, providerID, layout.MediaType(eff.Type))
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID), folderBase)); err == nil {
opt.Preview = links
}
}
@@ -911,25 +1052,136 @@ func mergeSourceOverrides(base, pins map[string]string) map[string]string {
}
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
// эффективные provider/provider_id (для тега папки и правила сходимости) (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.Plan, provider, providerID string, err error) {
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return recognize.Plan{}, "", err
return recognize.Plan{}, "", "", err
}
if rec == nil || !rec.Plan.Valid {
return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
return recognize.Plan{}, "", "", fmt.Errorf("no recognition plan")
}
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err)
return recognize.Plan{}, "", "", fmt.Errorf("parse plan: %w", err)
}
overrides, err := w.store.ListOverrides(ctx, id)
if err != nil {
return recognize.Plan{}, "", err
return recognize.Plan{}, "", "", err
}
prov, pid := effectiveProvider(rec, overrides)
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
return applyOverrides(plan, overrides), prov, pid, nil
}
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
// (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked.
func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "refresh_name", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
return w.refreshDisplayNameLocked(ctx, id)
}
// refreshDisplayNameLocked переливает уже вычисленное каноническое имя
// (эффективный план с учётом пинов) в download.display_name и в имя раздачи
// qBittorrent — без нового вызова LLM. Косметика: не влияет на пути/раскладку.
// Best-effort к qBittorrent: недоступность/отсутствие раздачи не проваливает
// операцию (display_name пишется в любом случае). Вызывается под w.mu.
func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
if d == nil {
return fmt.Errorf("refresh display name: download %s: %w", id, store.ErrNotFound)
}
cctx := w.scoped(ctx, capIngest, id, d.PrimaryInfohash())
rec, err := w.store.GetCurrentRecognition(cctx, id)
if err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
if rec == nil || !rec.Plan.Valid {
return nil // распознавания ещё нет — обновлять нечего (no-op)
}
plan, _, _, err := w.effectivePlan(cctx, id)
if err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
name := naming.FormatTitleYear(plan.Title, plan.Year)
if name == "" {
return nil // пустое распознанное название — no-op
}
if err := w.store.SetDisplayName(cctx, id, name); err != nil {
return fmt.Errorf("refresh display name: %w", err)
}
logctx.From(cctx).Info("display name refreshed", "display_name", name)
// Переименование раздачи — best-effort по её реальному Torrent.Hash (для
// гибрид/v2 он может не совпасть с нашим primary infohash). Отсутствие
// раздачи (удалена) — штатный no-op: display_name уже обновлён.
t, ok, err := w.torrentByInfohash(cctx, d.HashList())
if err != nil {
logctx.From(cctx).Warn("refresh display name: qbittorrent lookup failed", "error", err)
return nil
}
if !ok {
return nil
}
if err := w.qbt.RenameTorrent(cctx, t.Hash, name); err != nil {
logctx.From(cctx).Warn("refresh display name: qbittorrent rename failed", "error", err)
}
return nil
}
// Коды причины (error_code) ухода задачи в review при раскладке (linkPlan) —
// корреляционный ключ шага, на котором раскладка остановилась. Свод в одном
// месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg.
const (
reasonResolve = "resolve" // не удалось разрешить базу папки тайтла
reasonBuild = "build" // не удалось построить план ссылок
reasonPersist = "persist" // ссылки на диске, но учёт не записан
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
)
// resolveFolderBase применяет правило сходимости папки (см. file-layout spec):
// при подтверждённом матче наследует базу имени от живой папки-якоря того же
// (provider, providerID), кроме самой загрузки downloadID. Возвращает базу для
// layout.Plan.FolderBase (пусто → печатать из распознавания), флаг рассинхрона
// (≥2 разных живых папок) и ошибку. Чистая (только чтение БД+ФС), без побочных
// эффектов — переиспользуется применением и превью. Живость якоря определяется
// существованием папки на диске (os.Lstat), а не статусом ссылки в БД.
func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, providerID string, mt layout.MediaType) (base string, desync bool, err error) {
if provider == "" || provider == "none" || providerID == "" || w.layouter == nil {
return "", false, nil
}
paths, err := w.store.LiveTitleFolders(ctx, provider, providerID, downloadID)
if err != nil {
return "", false, fmt.Errorf("resolve folder base: %w", err)
}
bases := make(map[string]string, 2) // абсолютная папка тайтла → база имени
for _, p := range paths {
dir, b, ok := w.layouter.TitleFolder(mt, p)
if !ok {
continue // не под корнем / не разобрать — не якорь
}
if _, seen := bases[dir]; seen {
continue
}
if _, serr := os.Lstat(dir); serr != nil {
continue // папки на диске нет (переименована/удалена) — не якорь
}
bases[dir] = b
}
switch len(bases) {
case 0:
return "", false, nil
case 1:
for _, b := range bases {
return b, false, nil
}
}
return "", true, nil // рассинхрон: несколько разных живых папок
}
// --- Хелперы преобразования ---
@@ -1002,7 +1254,7 @@ func ProviderTag(provider, id string) string { return providerTag(provider, id)
// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд.
func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan {
return toLayoutPlan(p, srcPrefix, providerTag)
return toLayoutPlan(p, srcPrefix, providerTag, "")
}
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
@@ -1025,14 +1277,17 @@ func providerTag(provider, id string) string {
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
// относительные (для превью). providerTag добавляется к имени папки. Роли
// вне main/episode/subtitle отбрасываются.
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
// относительные (для превью). providerTag добавляется к имени папки. folderBase
// (непустой) — унаследованная от живого якоря база имени (правило сходимости):
// перекрывает Title/Year в папке и в именах файлов. Роли вне
// main/episode/subtitle отбрасываются.
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag, folderBase string) layout.Plan {
lp := layout.Plan{
Type: layout.MediaType(plan.Type),
Title: plan.Title,
Year: plan.Year,
ProviderTag: providerTag,
FolderBase: folderBase,
}
for _, f := range plan.Files {
role, ok := mapRole(f.Role)
@@ -1094,6 +1349,10 @@ func parseIgnored(s string) []string {
return nil
}
var out []string
// Ошибку разбора глотаем намеренно: битый JSON в override ignored_files
// (не должен возникать — пишем его сами через json.Marshal) трактуем как
// «нет игнора», а не роняем команду. Худший исход — файл не будет пропущен,
// человек увидит его в превью и пометит заново.
_ = json.Unmarshal([]byte(s), &out)
return out
}
+628 -11
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -310,6 +311,10 @@ type memStore struct {
links []store.FileLink
candidates []store.MetadataCandidate
torrents map[string][]byte
// Инъекция сбоев (для тестов устойчивости раскладки).
failCreateLinks error // CreateFileLinks вернёт эту ошибку
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
}
func newMemStore() *memStore {
@@ -436,6 +441,11 @@ func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, e
}
func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
if m.failSetState != nil {
if err := m.failSetState(st); err != nil {
return err
}
}
d := m.downloads[id]
d.State = st
d.ErrorCode = store.NullString(code)
@@ -453,6 +463,13 @@ func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) err
return nil
}
func (m *memStore) SetDisplayName(_ context.Context, id, name string) error {
if d, ok := m.downloads[id]; ok {
d.DisplayName = name
}
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
@@ -467,6 +484,13 @@ func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) e
return nil
}
func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
if d, ok := m.downloads[id]; ok {
d.RetriedAt = store.NullString(store.FormatTime(t))
}
return nil
}
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
@@ -516,6 +540,9 @@ func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]strin
}
func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
if m.failCreateLinks != nil {
return m.failCreateLinks
}
m.links = append(m.links, links...)
return nil
}
@@ -538,6 +565,27 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, d
}
return nil
}
func (m *memStore) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
if provider == "" || providerID == "" {
return nil, nil
}
match := map[string]bool{} // download_id с current recognition этого матча
for _, r := range m.recs {
if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID {
match[r.DownloadID] = true
}
}
var out []string
for _, l := range m.links {
if l.DownloadID == excludeDownloadID || !match[l.DownloadID] {
continue
}
if isLaidOut(l.Status) {
out = append(out, l.DstPath)
}
}
return out, nil
}
func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
for i := len(m.links) - 1; i >= 0; i-- {
if m.links[i].DownloadID == id {
@@ -853,6 +901,7 @@ func TestDefer(t *testing.T) {
type applyFixture struct {
w *Worker
st *memStore
qb *fakeQbt
downloads string
movies string
series string
@@ -892,7 +941,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
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}
return applyFixture{w: w, st: st, qb: qb, downloads: downloads, movies: movies, series: series}
}
func TestApply_LinksAndDone(t *testing.T) {
@@ -996,6 +1045,219 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
}
}
// --- Правило сходимости папки (проблема второго сезона) ---
// matchCurrentRec помечает current recognition загрузки id подтверждённым
// матчем (для правила сходимости).
func matchCurrentRec(f applyFixture, id, provider, providerID string) {
for _, r := range f.st.recs {
if r.DownloadID == id && r.IsCurrent {
r.Provider = store.NullString(provider)
r.ProviderID = store.NullString(providerID)
}
}
}
// seedLiveAnchor заводит живой якорь тайтла: current recognition матча +
// разложенную ссылку на папку folder под series, и создаёт эту папку на диске.
func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) {
t.Helper()
dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv")
if onDisk {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil {
t.Fatal(err)
}
}
f.st.recs = append(f.st.recs, &store.Recognition{
ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true,
Provider: store.NullString(provider), ProviderID: store.NullString(providerID),
})
f.st.links = append(f.st.links, store.FileLink{
DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv",
DstPath: dst, Kind: "video", Status: "linked",
})
}
// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён
// файлов якоря в тесте).
func folderBaseTest(folder string) string {
if i := strings.LastIndex(folder, " ["); i >= 0 {
return folder[:i]
}
return folder
}
func TestApply_FolderConvergence_SecondSeason(t *testing.T) {
// Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613
// совпадает с живым якорем «Фарго (2014)» → наследуем его базу.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Второй сезон лёг в папку якоря, база — унаследованная и в имени файла.
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err)
}
// Своей папки «Show (2006)» быть не должно.
if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil {
t.Error("создана отдельная папка вместо сходимости")
}
}
func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) {
// Матч есть, но живого якоря нет → печатаем базу из распознавания.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err)
}
}
func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) {
// Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится,
// базу не наследуем.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) {
// Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) {
// Две разные живые папки одного матча → рассинхрон → review, без раскладки.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
// Раскладки не было.
if len(f.st.links) != 2 { // только два якоря
t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links))
}
}
func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) {
// Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking
// (linking→review легален), а не прямым deferred→review (ребра в графе нет).
f := newApplyFixture(t, seriesResult().Plan)
f.st.downloads["1"].State = store.StateDeferred
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
}
func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) {
// Превью активного источника наследует ту же базу, что даст применение.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// Превью ведёт в папку якоря (относительные пути, с тегом).
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst)
}
}
func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) {
// Матча нет (provider=none), но рядом есть живой якорь — наследования быть не
// должно (сходимость только при подтверждённом матче).
f := newApplyFixture(t, seriesResult().Plan) // current rec без provider
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Без матча тег пуст, база из распознавания.
want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("без матча наследования нет; ожидали %q: %v", want, err)
}
}
func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) {
// Рассинхрон в превью НЕ переводит задачу в review (превью информационно):
// база печатается из распознавания, состояние остаётся review.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// База из распознавания (не унаследованная), тег текущего матча.
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst)
}
}
func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
// не отбирается (supersede не срабатывает для не-разложенного пути).
@@ -1049,6 +1311,125 @@ func TestUndo_RevertsLinks(t *testing.T) {
}
}
func TestDelete_FromDone_RemovesBothSides(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
d := f.st.downloads["1"]
if d.State != store.StateDeleted {
t.Errorf("state = %q, want deleted", d.State)
}
if d.ErrorCode.String != "user_delete" {
t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("library link must be removed: %v", err)
}
if len(f.st.links) != 0 {
t.Errorf("file_links must be deleted, got %d", len(f.st.links))
}
if len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
}
}
func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
// Undo бы отказал, Delete снимает осознанно.
for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
_ = os.Remove(filepath.Join(f.downloads, src))
}
f.qb.torrents = nil // раздачи в qBittorrent больше нет
f.st.downloads["1"].State = store.StateOrphaned
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("last-copy link must be removed despite guard: %v", err)
}
// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
if len(f.qb.deleted) != 1 {
t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
}
}
func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Цель удалена вручную, источник на месте.
dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
_ = os.Remove(dst1)
_ = os.Remove(dst2)
f.st.downloads["1"].State = store.StateTargetMissing
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
}
}
func TestDelete_WrongStateRejected(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan) // состояние review
err := f.w.Delete(context.Background(), "1")
if !errors.Is(err, ErrConflict) {
t.Fatalf("Delete from review = %v, want ErrConflict", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 0 {
t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
}
}
func TestDelete_QbtErrorKeepsState(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
f.qb.deleteErr = errors.New("qbittorrent boom")
if err := f.w.Delete(context.Background(), "1"); err == nil {
t.Fatal("Delete must return qbittorrent error")
}
// Ложно в deleted не уходим (место не освобождено).
if f.st.downloads["1"].State == store.StateDeleted {
t.Errorf("state = deleted despite qbt error; want unchanged (done)")
}
// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
// цель уже снята (file_links удалены), источник сносится, задача → deleted.
f.qb.deleteErr = nil
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("repeat Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
}
}
func TestReviewData(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
@@ -1235,18 +1616,154 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
t.Error("кандидат не помечен выбранным")
}
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" || plan.Year != 2014 {
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
}
if tag != "tvdbid-269613" {
if tag := providerTag(prov, pid); tag != "tvdbid-269613" {
t.Errorf("tag = %q", tag)
}
}
// refreshSetup: загрузка в review с распознаванием (movie «Догадка»/2000),
// display_name="Unknown", кандидатом cand и раздачей в qBit по ihTest — для
// проверок перелива имени после подтверждения матча.
func refreshSetup(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore, *fakeQbt) {
t.Helper()
st := newMemStore()
d := completedDownload("1")
d.State = store.StateReview
d.DisplayName = "Unknown"
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"),
})
cand.RecognitionID = "1"
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
// Ключ раздачи в qBit (Hash) НАМЕРЕННО отличается от нашего инфохэша: matched
// по InfohashV1. Так тест ловит, что rename идёт по реальному t.Hash (мотив
// D3: для гибрид/v2 t.Hash ≠ PrimaryInfohash), а не по сырому инфохэшу.
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: qbtHash, InfohashV1: ihTest, Name: "Unknown"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
return w, st, qb
}
// qbtHash — внутренний ключ раздачи в qBittorrent, отличный от инфохэша загрузки.
const qbtHash = "qbt-internal-key"
func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
const want = "Harold and the Purple Crayon (2024)"
if got := st.downloads["1"].DisplayName; got != want {
t.Errorf("display_name = %q, want %q", got, want)
}
// rename адресован по реальному t.Hash (qbtHash), а НЕ по инфохэшу загрузки.
if len(qb.renamed) != 1 || qb.renamed[0].hash != qbtHash || qb.renamed[0].name != want {
t.Errorf("renamed = %+v, want hash=%s name=%q", qb.renamed, qbtHash, want)
}
if qbtHash == ihTest {
t.Fatal("тест бессмыслен: qbtHash совпал с инфохэшем загрузки")
}
}
func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.renameErr = errors.New("qbit down")
// Best-effort: сбой переименования раздачи не должен ронять выбор кандидата.
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit rename failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой qBit)", got)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentMissing(t *testing.T) {
// Раздача удалена (нет в qBit) — display_name всё равно обновлён, rename нет.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrents = nil // torrentByInfohash не найдёт раздачу → best-effort no-op
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён и без раздачи)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (раздачи нет)", qb.renamed)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentsLookupError(t *testing.T) {
// Листинг qBit упал — display_name обновлён, rename не зовём, команда цела.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrentsErr = errors.New("qbit down")
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit lookup failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой листинга)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (листинг упал)", qb.renamed)
}
}
func TestAddManualSource_RefreshesDisplayName(t *testing.T) {
// Ручное добавление источника — тоже подтверждение матча → refresh.
// Кандидат без title/year → имя берётся из распознавания (fallback).
w, st, qb := refreshSetup(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
if err := w.AddManualSource(context.Background(), "1", "tmdb", "603"); err != nil {
t.Fatalf("AddManualSource: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Догадка (2000)" {
t.Errorf("display_name = %q, want fallback «Догадка (2000)»", got)
}
if len(qb.renamed) != 1 || qb.renamed[0].name != "Догадка (2000)" {
t.Errorf("renamed = %+v", qb.renamed)
}
}
func TestRefreshDisplayName_NoRecognitionNoop(t *testing.T) {
// Нет распознавания — обновлять нечего: display_name не трогаем, rename нет.
st := newMemStore()
d := completedDownload("1")
d.State = store.StateDownloading
d.DisplayName = ""
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "raw"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.RefreshDisplayName(context.Background(), "1"); err != nil {
t.Fatalf("RefreshDisplayName: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "" {
t.Errorf("display_name = %q, want empty (no-op)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none", qb.renamed)
}
}
func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
@@ -1280,8 +1797,8 @@ func TestClearProvider(t *testing.T) {
t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
}
// «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), "1")
if tag != "" {
_, prov, pid, _ := w.effectivePlan(context.Background(), "1")
if tag := providerTag(prov, pid); tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
}
@@ -1302,14 +1819,14 @@ func TestClearProvider_ClearsInheritedTitle(t *testing.T) {
t.Fatalf("ClearProvider: %v", err)
}
// Название/год снова из плана распознавания, тег пуст.
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" || plan.Year != 2000 {
t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
}
if tag != "" {
if tag := providerTag(prov, pid); tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
@@ -1333,14 +1850,14 @@ func TestAddManualSource_ChoosesAndClears(t *testing.T) {
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
}
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" { // безтайтловый источник → название из плана
t.Errorf("plan title = %q, want plan-default", plan.Title)
}
if tag != "tmdbid-603" {
if tag := providerTag(prov, pid); tag != "tmdbid-603" {
t.Errorf("tag = %q", tag)
}
}
@@ -1488,7 +2005,7 @@ func TestToLayoutPlan(t *testing.T) {
{Src: "sample.mkv", Role: "sample"},
},
}
lp := toLayoutPlan(plan, "/d", "tmdbid-1")
lp := toLayoutPlan(plan, "/d", "tmdbid-1", "")
if len(lp.Files) != 1 {
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
}
@@ -1530,7 +2047,7 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
{Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e},
},
}
lp := toLayoutPlan(plan, savePath, "")
lp := toLayoutPlan(plan, savePath, "", "")
if len(lp.Files) != 1 {
t.Fatalf("want 1 file, got %d", len(lp.Files))
}
@@ -1540,3 +2057,103 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
})
}
}
// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) ---
// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода
// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе
// ссылки лягут при задаче в review, а финальный linking→done граф отклонит.
func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
f.st.failSetState = func(st store.State) error {
if st == store.StateLinking {
return errors.New("boom: claim persist failed")
}
return nil
}
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("Apply must fail when linking claim persist fails")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State)
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links))
}
// Хардлинки на диск НЕ созданы — раскладка не запускалась.
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, statErr := os.Stat(dst); statErr == nil {
t.Errorf("hardlink %q created despite failed claim", dst)
}
}
// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но
// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит
// в review с причиной, повтор идемпотентен.
func TestApply_PersistFailureLeavesReview(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
f.st.failCreateLinks = errors.New("boom: sqlite busy")
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("Apply must fail when persisting links fails")
}
if f.st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != "persist" {
t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String)
}
// Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет.
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, statErr := os.Stat(dst); statErr != nil {
t.Errorf("expected hardlink on disk despite persist failure: %v", statErr)
}
}
// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking
// после краха, на тике/старте возвращается в review с причиной.
func TestSweepLinking_OrphanedToReview(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateLinking
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
w.sweepLinking(context.Background())
got := st.downloads["1"]
if got.State != store.StateReview {
t.Fatalf("state = %q, want review", got.State)
}
if got.ErrorCode.String != "interrupted" {
t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String)
}
if got.ErrorMsg.String == "" {
t.Error("expected error_msg with reason")
}
}
// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие
// состояния (в т.ч. done) не задевает.
func TestSweepLinking_LeavesOtherStates(t *testing.T) {
st := newMemStore()
done := completedDownload("1")
done.State = store.StateDone
st.put(done)
review := completedDownload("2")
review.State = store.StateReview
st.put(review)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
w.sweepLinking(context.Background())
if st.downloads["1"].State != store.StateDone {
t.Errorf("done task moved to %q", st.downloads["1"].State)
}
if st.downloads["2"].State != store.StateReview {
t.Errorf("review task moved to %q", st.downloads["2"].State)
}
}
+215 -35
View File
@@ -49,8 +49,15 @@ type Store interface {
// PromoteCatched атомарно переводит catched → downloading с записью имени
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
PromoteCatched(ctx context.Context, id, displayName string) error
// SetDisplayName обновляет отображаемое имя постфактум (перелив канонического
// имени после распознавания) — без гарда состояния, FSM не двигает.
SetDisplayName(ctx context.Context, id, name string) error
SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
// таймаутов (magnet_timeout/stuck_after), чтобы возврат в downloading не
// ронял задачу снова на ближайшем тике.
SetRetriedAt(ctx context.Context, id string, t time.Time) error
// Идентичность/инвариант «одна активная загрузка на infohash».
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
@@ -70,6 +77,9 @@ type Store interface {
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
CreateFileLinks(ctx context.Context, links []store.FileLink) error
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
// LiveTitleFolders — dst_path живых ссылок загрузок того же (provider,
// provider_id), кроме excludeDownloadID (правило сходимости папки).
LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error)
LatestBatchID(ctx context.Context, downloadID string) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
@@ -86,6 +96,9 @@ type QBittorrent interface {
Torrents(ctx context.Context, category string) ([]qbt.Torrent, error)
Add(ctx context.Context, ar qbt.AddRequest) error
Files(ctx context.Context, hash string) ([]qbt.File, error)
Delete(ctx context.Context, hashes []string, deleteFiles bool) error
// RenameTorrent задаёт имя уже добавленной раздачи по её ключу (Torrent.Hash).
RenameTorrent(ctx context.Context, hash, name string) error
}
// Recognizer — распознаватель (recognize.Recognizer).
@@ -104,6 +117,10 @@ type Layouter interface {
BuildLinks(p layout.Plan) ([]layout.Link, error)
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
Undo(ctx context.Context, links []layout.Link) (int, error)
Remove(ctx context.Context, links []layout.Link) (int, error)
// TitleFolder разбирает dst_path в папку тайтла и базу имени (правило
// сходимости папки). ok=false, если путь не под корнем нужной библиотеки.
TitleFolder(t layout.MediaType, dst string) (dir, base string, ok bool)
}
// NotifyEvent — повод позвать пользователя.
@@ -129,6 +146,12 @@ const (
// catch_timeout (устойчивая недоступность qBit). Раздачи в qBittorrent нет,
// восстановлению сверкой не подлежит.
errCodeQbitAdd = "qbit_add"
// errCodeSourceGone — раздача активной (downloading) загрузки устойчиво (после
// дебаунса source_missing_threshold) пропала из qBittorrent: пользователь/другой
// клиент её удалил. Distinct-код, отличный от qbit_error (реальная ошибка qBit) и
// magnet_timeout/stalled (наша нетерпеливость). Восстановлению сверкой НЕ подлежит
// (удаление намеренно) — но задача штатно retriable: Retry заново отдаёт источник.
errCodeSourceGone = "source_gone"
)
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
@@ -318,12 +341,39 @@ func (w *Worker) pollOnce(ctx context.Context) {
// Быстрый приём отложил добавление в qBittorrent: подхватываем пойманные
// (catched) загрузки и добавляем их (сеть — вне блокировки переходов).
w.processCatched(ctx)
// Восстанавливаем задачи, застрявшие в linking после краха между claim и
// финальным переходом (иначе их не листит никто — вечный лимбо).
w.sweepLinking(ctx)
// Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке).
if w.recognizer != nil {
w.recognizePending(ctx)
}
}
// sweepLinking восстанавливает задачи, застрявшие в состоянии linking. Любая
// linking-задача, видимая под w.mu, устарела по построению: активная раскладка
// (linkPlan) держит w.mu на всё время и завершает переход из linking ДО отпускания
// замка — значит эта задача осталась в linking после краха процесса между claim
// (переходом в linking) и финальным переходом. Возвращаем её в review с причиной;
// человек повторит Apply (linkPlan идемпотентен), и незаписанный учёт хардлинков
// допишется. Так у linking появляется владелец на рестарте/тике — инвариант «у
// каждого нетерминального состояния есть владелец» (как recognizePending для
// recognizing). Выполняется на каждом тике и на старте (первый pollOnce до цикла).
func (w *Worker) sweepLinking(ctx context.Context) {
w.mu.Lock()
defer w.mu.Unlock()
stuck, err := w.store.ListDownloadsByState(ctx, store.StateLinking)
if err != nil {
w.log.Warn("sweep linking list failed", "capability", capFileLayout, "error", err)
return
}
for _, d := range stuck {
lctx := w.scoped(ctx, capFileLayout, d.ID, d.PrimaryInfohash())
w.transition(lctx, d, store.StateReview, "interrupted",
"прерванная раскладка, повтори применение")
}
}
// processCatched — асинхронный шаг добавления пойманных загрузок в qBittorrent.
// Для каждой catched: (предохранитель) если висит дольше catch_timeout — уводим
// в failed; иначе выводим имя и добавляем в qBit. Медленные вызовы (LLM-namer,
@@ -470,9 +520,22 @@ func (w *Worker) Poll(ctx context.Context) error {
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
}
t, ok := torrentFor(d, byHash)
// Дебаунс пропажи источника у активной загрузки (тот же счётчик, что и
// сверка рассинхрона): промах наращивает source_miss_count, появление
// раздачи сбрасывает его. Устойчивая пропажа (после порога) уводит задачу
// в failed(source_gone) — иначе downloading без раздачи в qBittorrent
// оставался бы вечным зомби (MAJOR-3).
if present := w.debounceSource(ctx, d, ok); !present {
lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
logctx.From(lctx).Warn("active download source gone from qbittorrent",
"miss_count", d.SourceMissCount+1)
w.transition(lctx, d, store.StateFailed, errCodeSourceGone,
"источник удалён из qBittorrent")
continue
}
if !ok {
w.log.Warn("active download not found in qbittorrent",
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
// До порога: транзиентный промах (например рестарт демона qBit) — ждём
// следующий тик, задачу не трогаем.
continue
}
w.captureInfohashes(ctx, d, t)
@@ -506,21 +569,29 @@ func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent)
}
}
// checkTimeouts помечает зависшие задачи. Возраст считаем от факта в
// qBittorrent (added_on), а не от created_at: базис переживает retry и
// усыновление раздачи (см. design download-failure-recovery). magnet_timeout —
// редкий страховочный предохранитель (дефолт 24h); настоящие провалы ловит
// checkTimeouts помечает зависшие задачи двумя разными мерами:
// - magnet_timeout — по ВОЗРАСТУ торрента (metaDL дольше magnet_timeout без
// метаданных): страховочный предохранитель (дефолт 24h), базис — added_on;
// - stuck_after — по ДЛИТЕЛЬНОСТИ ПРОСТОЯ (stalledDL без движения данных
// дольше stuck_after): базис — last_activity, а не возраст. Иначе долго
// качавшийся торрент, на миг зашедший в stalledDL, ложно уходит в stuck со
// «stalled for 5h» (см. state-reconciliation, MAJOR-2).
//
// Оба базиса приподняты до retried_at (ручной retry), чтобы возврат в
// downloading не ронял задачу снова на ближайшем тике. Настоящие провалы ловит
// classErrored, а ожившие задачи воскрешает reconcileRecovery.
func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torrent) {
age := w.torrentAge(d, t)
switch {
case isMeta(t.State) && w.cfg.MagnetTimeout > 0 && age > w.cfg.MagnetTimeout:
case isMeta(t.State) && w.cfg.MagnetTimeout > 0:
if age, ok := w.torrentAge(d, t); ok && age > w.cfg.MagnetTimeout {
w.transition(ctx, d, store.StateFailed, errCodeMagnetTimeout,
fmt.Sprintf("no metadata after %s", age.Truncate(time.Second)))
case isStalledDL(t.State) && w.cfg.StuckAfter > 0 && age > w.cfg.StuckAfter:
}
case isStalledDL(t.State) && w.cfg.StuckAfter > 0:
if idle, ok := w.stallDuration(d, t); ok && idle > w.cfg.StuckAfter {
w.transition(ctx, d, store.StateStuck, errCodeStalled,
fmt.Sprintf("stalled for %s", age.Truncate(time.Second)))
fmt.Sprintf("stalled for %s", idle.Truncate(time.Second)))
}
}
}
@@ -572,33 +643,89 @@ func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.
}
}
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
// отдал added_on.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) time.Duration {
// torrentAge — возраст торрента для magnet_timeout: от added_on в qBittorrent
// (надёжный базис, переживает усыновление), с фолбэком на created_at задачи,
// если qBit не отдал added_on (NIT-10). Базис приподнят до retried_at, чтобы
// ручной retry сбрасывал отсчёт. ok=false — базис неизвестен (ни added_on, ни
// разбираемого created_at): таймаут не срабатывает, фиксируем диагностикой.
func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, bool) {
basis, ok := w.addedBasis(d, t)
if !ok {
return 0, false
}
return w.now().Sub(w.retriedFloor(d, basis)), true
}
// stallDuration — длительность простоя торрента для stuck_after: от
// last_activity qBittorrent (момент последнего движения данных), с фолбэком на
// базис добавления, если qBit не отдал пригодного last_activity. Базис приподнят
// до retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен.
//
// last_activity в будущем (перекос часов, sentinel «никогда не был активен»)
// трактуем как непригодное значение и падаем на addedBasis: иначе простой вышел
// бы отрицательным и реально застрявший торрент никогда бы не пометился stuck.
func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) {
var basis time.Time
if la := time.Unix(t.LastActivity, 0).UTC(); t.LastActivity > 0 && !la.After(w.now()) {
basis = la
} else {
var ok bool
if basis, ok = w.addedBasis(d, t); !ok {
return 0, false
}
}
return w.now().Sub(w.retriedFloor(d, basis)), true
}
// addedBasis — момент добавления торрента: added_on qBittorrent, иначе
// created_at задачи (NIT-10). ok=false — ни того, ни другого разобрать не
// удалось; фиксируем диагностикой.
func (w *Worker) addedBasis(d store.Download, t qbt.Torrent) (time.Time, bool) {
if t.AddedOn > 0 {
return w.now().Sub(time.Unix(t.AddedOn, 0).UTC())
return time.Unix(t.AddedOn, 0).UTC(), true
}
created, err := d.CreatedTime()
if err != nil {
// Ни added_on от qBit, ни разбираемого created_at — возраст неизвестен,
// таймауты не сработают; фиксируем диагностикой.
w.log.Warn("cannot determine torrent age",
"capability", capIngest, "download_id", d.ID,
"created_at", d.CreatedAt, "error", err)
return 0
return time.Time{}, false
}
return w.now().Sub(created)
return created, true
}
// transition пишет новое состояние и логирует переход.
// retriedFloor приподнимает базис отсчёта таймаута до времени последнего ручного
// retry: после retry задача получает свежее окно и не падает повторно на
// ближайшем тике (см. state-reconciliation «Ручной повтор», MAJOR-1).
func (w *Worker) retriedFloor(d store.Download, basis time.Time) time.Time {
if r, ok := d.RetriedTime(); ok && r.After(basis) {
return r
}
return basis
}
// transition пишет новое состояние и логирует переход. Fire-and-forget обёртка
// над transitionErr: применяется там, где переход терминален для шага — за ним
// нет побочного эффекта, зависящего от факта записи claim (reconcile, таймауты,
// команды ревью, финальные переходы linkPlan, sweep). Ошибку записи гасит (её
// уже залогировал transitionErr).
func (w *Worker) transition(ctx context.Context, d store.Download, state store.State, code, msg string) {
_ = w.transitionErr(ctx, d, state, code, msg)
}
// transitionErr пишет новое состояние, шлёт пинги/скан, логирует переход и
// ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (ручное Apply,
// авто-раскладка в finishRecognition) провал claim перехода в `linking` ОБЯЗАН
// прервать выполнение ДО побочных эффектов (хардлинков): иначе ссылки лягут при
// незакоммиченном claim, а финальный переход из фактического (не `linking`)
// состояния граф отклонит — задача застрянет со stale-планом (MINOR-7).
func (w *Worker) transitionErr(ctx context.Context, d store.Download, state store.State, code, msg string) error {
// FromOr, а не From: если вызывающий не завёл scoped-логгер, падаем на
// w.log (настроенный), а не на slog.Default().
log := logctx.FromOr(ctx, w.log)
if err := w.store.SetDownloadState(ctx, d.ID, state, code, msg); err != nil {
log.Error("state transition failed", "from", d.State, "to", state, "error", err)
return
return fmt.Errorf("transition %s → %s: %w", d.State, state, err)
}
log.Info("state transition", "from", d.State, "to", state, "code", code)
@@ -631,6 +758,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
}
return nil
}
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
@@ -652,9 +780,34 @@ func (w *Worker) shouldNotifyFail(id string) bool {
return true
}
// logCmd — единый чокпоинт логирования исхода команды воркера
// (apply/cancel/retry/…), вызываемой транспортами. Конвенция (logging.md,
// раздел «Ошибки»): доменную ошибку логирует граница домена ровно один раз, а
// транспорты (HTTP/web/Telegram) — нет. Команды воркера и есть эта граница.
//
// Уровень — по адресату: штатный отказ по состоянию/наличию
// (ErrConflict/ErrNotReady/ErrNotFound) адресован пользователю, он уже получил
// ответ на поверхности — DEBUG; всё прочее (сбой БД/ФС/зависимости) адресовано
// команде — ERROR. Успех (err == nil) — молча. Ставится в defer при именованном
// возврате, поэтому видит финальную ошибку и scoped-логгер, накопленный в ctx.
func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
if err == nil {
return
}
log := logctx.FromOr(ctx, w.log)
switch {
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision):
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
default:
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
}
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id string) error {
func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "cancel", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -663,18 +816,20 @@ func (w *Worker) Cancel(ctx context.Context, id string) error {
return fmt.Errorf("cancel: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
return fmt.Errorf("cancel: download %s is already terminal (%s): %w", id, d.State, ErrConflict)
}
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
return fmt.Errorf("cancel: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
"from", d.State, "to", store.StateCancelled)
return nil
}
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id string) error {
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "retry", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -683,18 +838,24 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
return fmt.Errorf("retry: %w", err)
}
if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
}
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике.
alive := false
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
reAdd := true
if hashes := d.HashList(); len(hashes) > 0 {
_, alive, err = w.torrentByInfohash(ctx, hashes)
var t qbt.Torrent
var alive bool
t, alive, err = w.torrentByInfohash(ctx, hashes)
if err != nil {
return fmt.Errorf("retry: %w", err)
}
reAdd = !alive || classify(t.State) == classErrored
}
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
// в failed, тем же infohash могла завладеть другая активная задача — тогда
@@ -705,7 +866,7 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
}
return fmt.Errorf("retry: %w", err)
}
if !alive {
if reAdd {
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
// не зовём — имя уже могло быть выведено при первом добавлении).
@@ -729,7 +890,26 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
return fmt.Errorf("retry: add to qbittorrent: %w", err)
}
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
// Сброс базиса отсчёта таймаутов (MAJOR-1): без него живой, но давно
// добавленный/простаивающий торрент снова упал бы по magnet_timeout/
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
// (WARN), сам retry уже состоялся.
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
w.log.Warn("retry basis reset failed",
"capability", capReview, "download_id", id, "error", err)
}
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем
// тике, если переотданная раздача ещё не видна в выдаче qBittorrent — без
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
if d.SourceMissCount != 0 {
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
w.log.Warn("retry miss count reset failed",
"capability", capReview, "download_id", id, "error", err)
}
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
"from", d.State, "to", store.StateDownloading)
return nil
}
+56 -2
View File
@@ -191,6 +191,15 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
return nil
}
func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.DisplayName = name
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {
@@ -211,6 +220,15 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
return nil
}
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.RetriedAt = store.NullString(store.FormatTime(t))
return nil
}
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
@@ -226,6 +244,9 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]strin
return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) LiveTitleFolders(_ context.Context, _, _, _ string) ([]string, error) {
return nil, nil
}
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
return nil
}
@@ -247,9 +268,19 @@ func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { r
type fakeQbt struct {
torrents []qbt.Torrent
torrentsErr error
added []qbt.AddRequest
addErr error
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error
}
type renameCall struct {
hash string
name string
}
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
@@ -257,6 +288,9 @@ type fakeQbt struct {
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if f.torrentsErr != nil {
return nil, f.torrentsErr
}
if category == "" {
return f.torrents, nil
}
@@ -281,6 +315,22 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
return f.files, nil
}
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
if f.deleteErr != nil {
return f.deleteErr
}
f.deleted = append(f.deleted, hashes)
return nil
}
func (f *fakeQbt) RenameTorrent(_ context.Context, hash, name string) error {
if f.renameErr != nil {
return f.renameErr
}
f.renamed = append(f.renamed, renameCall{hash, name})
return nil
}
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
w := New(st, qb, nil, nil, Config{
Category: "jellybit",
@@ -361,15 +411,19 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
}
}
func TestPollIgnoresMissingTorrent(t *testing.T) {
// Единичный транзиентный промах (торрента нет в этом тике) при пороге дебаунса
// > 1 не двигает состояние — устойчивую пропажу и переход в source_gone проверяют
// TestSourceGone* в recovery_test.
func TestPollToleratesTransientMissingTorrent(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет
w := newTestWorker(st, qb)
w.cfg.SourceMissingThreshold = 3 // порог не достигнут за один тик
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
t.Errorf("единичный промах не должен менять состояние, got %q", st.downloads["1"].State)
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,135 @@
# Design — Пропажа источника у активной загрузки
## Контекст
`Poll` под `w.mu` листает `downloading`-задачи и для каждой ищет торрент в
`byHash`. Промах сейчас — только `Warn` + `continue` (worker.go:503-508). Механизм
дебаунса пропажи источника уже есть в `state-reconciliation`: поле
`download.source_miss_count`, метод `debounceSource(ctx, d, sourceSeen) bool` и
порог `[worker].source_missing_threshold` (дефолт 3). Он применяется только в
`reconcileOneDesync`. Задача — переиспользовать его в активном цикле.
## Решение
### Целевое состояние: `failed`/`source_gone`, а не `deleted`
Развилка из беклога: «downloading → `deleted` ИЛИ `failed` с distinct error_code
для re-Add». Выбран **`failed`/`source_gone`**:
- **Восстановимость.** Источник у нас сохранён (magnet `source_ref` или байты
`.torrent`). `Retry` заново отдаёт его в qBittorrent — задача продолжится.
`deleted` терминален навсегда и не оставляет пользователю выхода, хотя пропажа
могла быть случайной.
- **Минимальная дельта графа.** Ребро `downloading → failed` уже объявлено
(`allowedTransitions`), нового ребра не нужно. `downloading → deleted` ребра нет
— пришлось бы вводить.
- **Переиспользование инфраструктуры.** `StateFailed` уже шлёт `EventFailed`
(с дебаунсом уведомлений) и уже retriable — не нужен ни новый notify-повод, ни
новая команда.
- **Distinct `error_code`.** `source_gone` отличается от `qbit_error`
(реальная ошибка qBit), `magnet_timeout`/`stalled` (наша нетерпеливость) — по
нему UI/лог различает причину, и он **осознанно не в наборе** `reconcileRecovery`
(`ListRecoverable(magnet_timeout, stalled)`): намеренно удалённый источник не
должен молча воскресать, у пользователя есть явный `Retry`.
`deriveState(false, false) = deleted` (матрица «источник × цель») здесь НЕ
применяется: у активной `downloading`-задачи цели ещё нет, и это не путь сверки
рассинхрона, а отдельное правило прямого пути. Матрица по-прежнему не трогает
активные состояния.
### Переиспользование дебаунса в активном цикле
`debounceSource(ctx, d, sourceSeen)`:
- `sourceSeen=true` → сбрасывает счётчик в 0, возвращает `true`;
- `sourceSeen=false` → инкремент, возвращает `miss < threshold`.
Активный цикл `Poll` перестраивается так:
```go
t, ok := torrentFor(d, byHash)
if present := w.debounceSource(ctx, d, ok); !present {
// порог промахов исчерпан — источник действительно пропал
lctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
w.transition(lctx, d, store.StateFailed, errCodeSourceGone,
"источник удалён из qBittorrent")
continue
}
if !ok {
// до порога: транзиентный промах (рестарт qBit) — ждём следующий тик
continue
}
w.captureInfohashes(ctx, d, t)
w.captureSourceAddedAt(ctx, d, t)
w.reconcile(ctx, d, t)
```
`debounceSource` вызывается в ЛЮБОМ случае (и при `ok`, и при промахе), поэтому
появление раздачи сбрасывает счётчик, накопленный ранее.
### Сброс `source_miss_count` при `Retry` (иначе нет грейс-окна)
`source_gone` — единственный путь, оставляющий у **retriable** задачи ненулевой
`source_miss_count` (у `magnet_timeout`/`stalled` источник на падающем тике
присутствовал, значит счётчик уже 0). Без сброса `Retry` вернул бы задачу в
`downloading` с `source_miss_count == threshold`: на первом же тике, если
переотданная раздача ещё не видна в выдаче qBittorrent (Add без ошибки, но
регистрация с задержкой), `debounceSource` даёт `miss = threshold+1` → мгновенный
повторный `source_gone`, минуя обещанное спекой грейс-окно.
Поэтому `Retry` SHALL сбрасывать `source_miss_count` в 0 — рядом с существующим
сбросом `retried_at` (та же интенция «свежее окно», MAJOR-1), best-effort. Это
покрывает общий случай: любая retriable-задача входит в `downloading` с чистым
счётчиком. Путь авто-восстановления (`reconcileRecovery`) сброса не требует —
туда `source_gone` не попадает, а у `magnet_timeout`/`stalled` счётчик уже 0.
### Почему нет двойного учёта `source_miss_count`
Задача в один момент времени находится ровно в одном состоянии: либо в активном
цикле (`downloading`), либо в `reconcileDesync` (`done`/`target_missing`/
`orphaned`) — не в обоих за тик. Поле `source_miss_count` используется с единой
семантикой «сброс при наличии источника», поэтому пересечения нет. При переходе
`downloading → completed` счётчик уже 0 (источник виден на том же тике).
### catched-исключение сохраняется
`catched`-задачи в активный цикл не попадают (там нет раздачи по дизайну) —
требование «catched не считается пропажей раздачи» не затрагивается: цикл листает
только `StateDownloading`.
## Альтернативы
- **`downloading → deleted`** — отклонено: терминально без выхода, требует нового
ребра, теряет ещё-скачиваемую задачу при, возможно, случайной пропаже.
- **Авто-восстановление `source_gone` при возврате раздачи** (добавить в
`reconcileRecovery`) — отклонено для v1: намеренное удаление не должно тихо
оживать; есть явный `Retry`. Отложено (можно добавить позже отдельным change,
если появится боль).
- **Отдельный notify-повод `EventSourceGone`** — избыточно: `EventFailed`
семантически покрывает «задача упала», текст уведомления берётся по состоянию.
## Принятые ограничения (вне scope)
- **Дебаунс уведомлений может проглотить пинг.** `shouldNotifyFail` дебаунсит
`EventFailed` по `download_id` на 1 ч. Узкая последовательность (задача уже
падала `stalled`/`stuck` с пингом < 1 ч назад → `Retry` → источник удалён →
`source_gone` в то же окно) не пришлёт повторный пинг. Приемлемо: смена
состояния и телеметрия всё равно фиксируются (главная боль зомби — «никто не
двигает» — закрыта); отдельный пинг именно про source_gone не критичен.
- **Задача `downloading` с пустым `Infohashes`** остаётся вне правила: гард
`len(d.Infohashes)==0 { continue }` стоит до `torrentFor` (как и в
`reconcileOneDesync`). В Ф1 не случается (magnet всегда с infohash); отдельный
класс зомби, этим change не адресуется.
- **Пустая выдача qBittorrent при живом демоне** (HTTP 200 сразу после рестарта,
resume-data ещё не загружены) нарастит промахи всем активным задачам. Экспозиция
предсуществующая и общая с `reconcileDesync` (тот массово пометил бы
`orphaned`/`deleted`); change лишь распространяет её на активные загрузки.
Дебаунс (`source_missing_threshold`) — уже имеющаяся защита; принимаем.
## Тесты
- Промах меньше порога → задача остаётся `downloading` (дебаунс).
- Промах ≥ порога → `downloading → failed`/`source_gone` + `EventFailed`.
- Возврат раздачи до порога → счётчик сброшен, задача жива, ушла по обычному
reconcile.
- `source_gone` не воскрешается `reconcileRecovery` (источник вернулся — задача
остаётся `failed` до ручного `Retry`).
@@ -0,0 +1,47 @@
# Пропажа источника у активной загрузки (MAJOR-3)
## Why
Если раздача исчезает из qBittorrent (пользователь или другой клиент удалил её),
пока задача в `downloading`, задача становится **вечным зомби**. Поллинг активных
загрузок промахивается по `torrentFor`, пишет `Warn "active download not found in
qbittorrent"` и делает `continue` — и так каждый тик, бесконечно. Состояние не
меняется, уведомления нет, телеметрии нет, `checkTimeouts` требует торрент (значит
таймауты-предохранители не срабатывают). Единственный выход — ручной Cancel/Defer.
Дыра: сверка рассинхрона (`state-reconciliation`) покрывает пропажу источника
только для уже разложенных состояний (`done`/`target_missing`/`orphaned`), а
восстановление (`reconcileRecovery`) — только `failed`/`stuck`. Для активного
`downloading` пропажей источника не владеет НИКТО. Сравни: та же пропажа в
`completed`/`recognizing`/`review` деградирует штатно (там источник уже не нужен
или его отсутствие ведёт через матрицу).
## What Changes
- Поллинг активных загрузок SHALL применять **дебаунс пропажи источника** (тот же
`SourceMissCount` / `[worker].source_missing_threshold`, что и сверка
рассинхрона): промах `torrentFor` наращивает счётчик, любое появление раздачи
его сбрасывает.
- После порога подряд идущих промахов задача SHALL переходить `downloading →
failed` с новым отдельным `error_code` `source_gone` и уведомлять автора
(`EventFailed`).
- `source_gone` **восстановлению сверкой не подлежит** (не входит в набор
`reconcileRecovery`): удаление источника из qBittorrent — намеренное действие,
молча воскрешать задачу нельзя. Задача остаётся штатно **retriable**: `Retry`
заново отдаёт источник (у нас сохранены magnet/`.torrent`-байты).
Ребро графа `downloading → failed` уже объявлено — нового ребра не требуется.
Меняется только набор `error_code` и поведение поллинга активных загрузок.
## Capabilities
- `download-tracking` — ADDED: «Пропажа источника у активной загрузки».
## Impact
- Код: `internal/worker/worker.go` (поллинг активных загрузок, новый
`errCodeSourceGone`), тесты воркера.
- Спека: `download-tracking` (новое требование). Диаграмма `docs/specs/workflow.md`
(миррор FSM) — добавить ребро `downloading → failed (source_gone)`.
- Миграции БД нет: `source_miss_count` уже существует, новых полей не вводим.
- Конфиг без изменений: переиспользуем `source_missing_threshold`.
@@ -0,0 +1,58 @@
# download-tracking Specification
## ADDED Requirements
### Requirement: Пропажа источника у активной загрузки
Поллинг активных загрузок (`downloading`) SHALL обнаруживать пропажу источника:
если раздача, совпадающая с любым из известных хешей загрузки, отсутствует в
выдаче qBittorrent, система SHALL применять **тот же дебаунс пропажи источника**,
что и сверка рассинхрона (счётчик `source_miss_count`, порог
`[worker].source_missing_threshold`; см. `state-reconciliation` «Дебаунс пропажи
источника»). Любое обнаружение раздачи SHALL сбрасывать счётчик.
После `N` подряд идущих тиков без раздачи (`N =
[worker].source_missing_threshold`) система SHALL переводить загрузку
`downloading → failed` с `error_code` `source_gone` и уведомлять автора. До
достижения порога загрузка SHALL оставаться в `downloading` (транзиентная
недоступность qBittorrent, например рестарт демона, не должна ронять задачу).
`source_gone` система SHALL трактовать как отдельную причину, отличную от
`qbit_error` (реальная ошибка qBittorrent) и от `magnet_timeout`/`stalled` (наша
нетерпеливость). Восстановлению сверкой (`reconcileRecovery`) `source_gone`
подлежать SHALL NOT — удаление источника из qBittorrent намеренно, молча
воскрешать задачу нельзя. Задача SHALL оставаться штатно восстановимой вручную
(`Retry` заново отдаёт сохранённый источник в qBittorrent).
Состояние `catched` этим правилом затрагиваться SHALL NOT: у пойманной загрузки
раздачи в qBittorrent ещё нет по дизайну (см. «catched не считается пропажей
раздачи»), а цикл активных загрузок листает только `downloading`.
#### Scenario: Источник пропал у активной загрузки дольше порога
- **GIVEN** загрузка в `downloading`, чья раздача удалена из qBittorrent
- **WHEN** раздача отсутствует `source_missing_threshold` подряд идущих тиков
- **THEN** загрузка переходит в `failed` с `error_code` `source_gone`
- **AND** автор загрузки уведомляется
#### Scenario: Кратковременная пропажа источника не роняет задачу
- **GIVEN** загрузка в `downloading`
- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
тиков подряд
- **THEN** загрузка остаётся в `downloading`
#### Scenario: Возврат раздачи сбрасывает счётчик
- **GIVEN** загрузка в `downloading` с накопленными промахами источника (меньше
порога)
- **WHEN** раздача снова обнаружена в qBittorrent
- **THEN** счётчик промахов сбрасывается в ноль и загрузка ведётся обычной
сверкой состояния
#### Scenario: source_gone не воскрешается сверкой
- **GIVEN** загрузка в `failed` с `error_code` `source_gone`
- **WHEN** её раздача снова появляется в qBittorrent и продвигается
- **THEN** сверка восстановления её не трогает — задача остаётся в `failed` до
ручного `Retry`
@@ -0,0 +1,38 @@
# Tasks — Пропажа источника у активной загрузки
## 1. Код воркера
- [x] 1.1 Ввести `errCodeSourceGone = "source_gone"` в блок кодов ошибок
(`internal/worker/worker.go`) с комментарием: distinct-код, восстановлению
сверкой не подлежит, retriable вручную.
- [x] 1.2 Перестроить активный цикл в `Poll`: при промахе `torrentFor` прогонять
`debounceSource`; при исчерпании порога — `transition downloading → failed`
(`source_gone`); до порога — `continue`; при наличии источника — сброс счётчика
и обычная сверка (`captureInfohashes`/`captureSourceAddedAt`/`reconcile`).
- [x] 1.3 Убедиться, что `debounceSource` вызывается и при наличии источника
(сброс накопленных промахов).
- [x] 1.4 `Retry` сбрасывает `source_miss_count` в 0 (рядом с `SetRetriedAt`),
чтобы retried `source_gone`-задача получила полное грейс-окно, а не падала
сразу (см. design «Сброс source_miss_count при Retry»).
## 2. Тесты
- [x] 2.1 Промах < порога → задача остаётся `downloading`.
- [x] 2.2 Промах ≥ порога → `downloading → failed`/`source_gone` + `EventFailed`.
- [x] 2.3 Возврат раздачи до порога → счётчик сброшен, задача ведётся обычной
сверкой.
- [x] 2.4 `source_gone` не воскрешается `reconcileRecovery` (источник вернулся —
задача остаётся `failed`).
- [x] 2.5 `Retry` задачи `source_gone` сбрасывает `source_miss_count`: после
retry с ещё-не-видимой раздачей задача остаётся `downloading` полный порог
тиков (грейс-окно не съедено).
## 3. Документация
- [x] 3.1 `docs/specs/workflow.md`: добавить ребро `downloading → failed`
(`source_gone`, пропажа источника) в диаграмму и список переходов.
## 4. Верификация
- [x] 4.1 `task test` / `task lint` зелёные.
- [x] 4.2 `openspec validate downloading-source-gone --strict`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,79 @@
## Context
Приём дедуплицирует входящий источник по активной задаче двумя путями:
1. **Быстрый чек**`Ingest` вызывает `FindActiveByInfohash`, при попадании
уходит в `attached()` (дозапись недостающих хешей через `AddInfohashes`).
Это основной путь.
2. **Гонка** — быстрый чек пуст, но пока выводили имя/готовили запись,
активная задача появилась; `CreateDownloadIfNoActive` внутри своей
транзакции находит её и возвращает как дедуп (дозапись хешей внутри той же
tx).
F1 живёт в пути 2 (неохраняемая дозапись). F6 задевает ОБА пути: в реальном
сценарии первым отрабатывает быстрый чек (путь 1), поэтому апгрейд обязан
работать и там.
## F1 — пер-хеш гард дозаписи в дедуп-ветке
**Решение.** В дедуп-ветке `CreateDownloadIfNoActive` заменяем безусловный
цикл `INSERT OR IGNORE` на тот же гард, что в `AddInfohashes`: для каждого
хеша `h` проверяем `findActiveByInfohash(ctx, tx, {h}, existing.ID)`; если
другая активная задача владеет `h` — пропускаем (не дописываем), иначе
`INSERT OR IGNORE`. Транзакция уже открыта, `excludeID = existing.ID`
исключает саму дедуп-цель (её собственные хеши не конфликтуют с ней самой).
**Почему пропуск, а не ошибка.** Дедуп по контракту не падает — возвращает
существующую задачу. Конфликтный хеш принадлежит другой активной задаче;
молча его не трогаем — это и есть сохранение инварианта. В отличие от
`AddInfohashes`, который сигналит `ErrInfohashTaken` вызывающему (там это
осмысленно), у дедуп-ветки наблюдаемого канала ошибки нет и он не нужен:
поведение — «присоединиться к найденной задаче, чужое не красть».
## F6 — апгрейд catched-magnet до torrent
**Решение.** Новый guarded-метод хранилища:
```
UpgradeCatchedMagnetToTorrent(ctx, downloadID string, torrentBlob []byte) (bool, error)
```
в одной write-транзакции:
1. Пустой `torrentBlob``(false, nil)` (защитный no-op).
2. Гардированный UPDATE:
`UPDATE download SET source_type='torrent', updated_at=?
WHERE id=? AND source_type='magnet' AND state='catched'`.
`RowsAffected==0` → задача не подходит (уже `downloading`/отменена/не
magnet) → коммит без эффекта, `(false, nil)`.
3. `RowsAffected==1` → `INSERT OR REPLACE INTO download_torrent(download_id,
data)` (у magnet блоба нет; `OR REPLACE` — страховка идемпотентности),
коммит, `(true, nil)`.
**Почему гард `state='catched'`.** Апгрейд имеет смысл только пока worker ещё
не отдал источник в qBittorrent. В `catched` worker на шаге добавления
выбирает способ по `source_type` (`internal/worker/worker.go:sourceAddParts`):
после смены на `torrent` он добавит файлом — метаданные приедут сразу. Если
задача уже `downloading`, magnet давно в qBittorrent (застрял в metaDL), и
смена `source_type` его не переотдаст; это отдельная забота retry/desync, не
приёма. Тот же паттерн ре-валидации, что у `PromoteCatched`: если задачу
успели отменить, UPDATE не заденет строк и апгрейд просто не применится.
**Где вызываем.** Оба дедуп-пути `Ingest` сводим к `attached()` (в ветке
гонки `existing` от `CreateDownloadIfNoActive` уже с подгруженными хешами, так
что переиспользование безопасно). В `attached()` после дозаписи хешей: если
входящий источник — `torrent` и есть байты, зовём
`UpgradeCatchedMagnetToTorrent`. Вызов best-effort: ошибка/неуспех логируются
`Warn`/`Info`, приём не валится (как и дозапись хешей). Результат приёма
по-прежнему несёт `State` существующей задачи (остаётся `catched`) — меняется
лишь способ будущего добавления.
**Идемпотентность и повтор.** Повторный `.torrent` того же хеша: первый
апгрейд перевёл задачу в `torrent`, гард `source_type='magnet'` на втором даст
`RowsAffected==0` → no-op. Байты уже сохранены при создании torrent-ветки —
`OR REPLACE` перезапишет теми же данными без вреда.
## Границы
Схему не трогаем: `download_torrent` и колонка `source_type` уже есть. ER-схема
`docs/specs/database.md` без изменений. Апгрейд — операция над данными.
@@ -0,0 +1,56 @@
## Why
Ревью приёма (Fable, 2026-07-08) вскрыло два дефекта в дедуп-ветках приёма,
оба про инвариант «≤1 активная загрузка на infohash» и про сохранность
источника:
- **F1 — неохраняемая дозапись хешей.** Дедуп-ветка
`CreateDownloadIfNoActive` (`internal/store/download.go`) безусловно
дописывает ВСЕ хеши входящего источника в найденную активную задачу
(`INSERT OR IGNORE`) без пер-хеш гарда владения — в отличие от
`AddInfohashes`, где гард есть. Это единственная неохраняемая запись хешей,
и она в авторитетном методе инварианта. Сценарий: активная A владеет `v1`,
активная B владеет `v2` того же гибридного торрента; приём гибрида
`{v1,v2}`, дедупнувшись на B, допишет `v1` в B → две активные владеют `v1`.
Инвариант нарушен.
- **F6 — потерянный upgrade-путь `.torrent` поверх magnet.** Пользователь
сначала ловит magnet с закрытого трекера (задача `catched`,
`source_type=magnet`), понимает, что без DHT метаданные не докачаются, и
грузит правильный `.torrent`. Приём дедупит по infohash на magnet-задачу; по
текущей спеке байты при дедупе НЕ сохраняются, `source_type` остаётся
`magnet`. Worker добавляет раздачу по magnet-URL → вечный metaDL → failed.
Ровно тот артефакт, который бы починил загрузку, выбрасывается с «уже в
работе».
## What Changes
- **F1:** дедуп-ветка `CreateDownloadIfNoActive` применяет тот же пер-хеш
гард, что и `AddInfohashes`: хеш, которым владеет ДРУГАЯ активная задача,
не дописывается. Транзакция уже открыта — правка внутри неё.
- **F6:** при дедупе, где входящее — байты `.torrent`, а активная задача
поймана как `magnet` и ещё не добавлена в qBittorrent (состояние
`catched`), система сохраняет байты и меняет `source_type` на `torrent` в
одной транзакции. Тогда worker добавит раздачу файлом и метаданные не
придётся докачивать по DHT. Это ПРОТИВОРЕЧИТ действующему правилу спеки
ingest «при дедупликации байты сохраняться SHALL NOT» — правило смягчается
этим целевым исключением (MODIFIED-дельта).
Схема БД не меняется: таблица `download_torrent` уже есть, `source_type`
существующая колонка; апгрейд — изменение данных, не структуры.
## Capabilities
### Modified Capabilities
- `ingest`: уточняется поведение дедупа (пер-хеш гард дозаписи; сохранение
`.torrent`-байт и смена источника при апгрейде `catched`-magnet).
## Impact
- Код: `internal/store/download.go` (гард F1 + новый guarded-метод апгрейда),
`internal/ingest/ingest.go` (вызов апгрейда на обоих дедуп-путях).
- Тесты: `internal/store`, `internal/ingest`.
- Совместимость: изменение только ужесточает инвариант (F1) и добавляет
целевой апгрейд (F6); существующие потоки без `.torrent`-поверх-magnet
ведут себя как прежде.
@@ -0,0 +1,154 @@
## MODIFIED Requirements
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
возвращающем загрузку из терминального состояния в активное (ручной retry,
воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её
(приём, adopt чужой раздачи), что никакая другая активная загрузка не
владеет любым из хешей этой, и при владении SHALL отказывать в переходе,
сохраняя инвариант «не более одной активной загрузки на infohash».
Отказ SHALL происходить до побочных эффектов во внешних системах
(повторного добавления торрента в qBittorrent).
Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие
гибридного торрента) на ВСЕХ путях дозаписи, включая дедуп-дозапись при
приёме: хеш, которым владеет другая активная загрузка, дописан быть SHALL
NOT — ни отдельным методом дозаписи, ни дедуп-веткой атомарного заведения,
которая доносит недостающие хеши найденной активной задаче. Прямой перевод
терминальной загрузки в активное состояние в обход этой проверки SHALL
отклоняться хранилищем (механический бэкстоп вместо удалённого
unique-индекса).
#### Scenario: Retry при занятом хеше
- **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка
#2 с тем же `h`
- **WHEN** пользователь вызывает retry для #1
- **THEN** переход отклоняется с пояснением, #1 остаётся в `failed`
- **AND** активной по `h` остаётся #2
#### Scenario: Дедуп-дозапись не крадёт чужой хеш
- **GIVEN** активная загрузка A владеет хешем `v1`, активная загрузка B
владеет хешем `v2` того же гибридного торрента
- **WHEN** принимается источник с обоими хешами `{v1, v2}` и дедупится на B
- **THEN** B получает только незанятые хеши, а `v1` (в собственности A) B не
дописывается
- **AND** инвариант «не более одной активной загрузки на infohash»
сохраняется (по `v1` активна только A)
### Requirement: Приём источника из .torrent-файла
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
непустые байты торрента, система SHALL разобрать их локально (без сети),
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
добавление делает воркер, см. `download-tracking`).
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
«Множество инфохэшей загрузки».
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
`catched` + записи `download_infohash`) и инвариант «не более одной активной
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
заведение загрузки; при дедупликации (новая загрузка не создана) байты в общем
случае сохраняться SHALL NOT.
**Исключение — апгрейд пойманной magnet-задачи до torrent.** Если входящий
источник — байты `.torrent`, а дедуп попал на активную загрузку с
`source_type = magnet`, ещё НЕ отданную в qBittorrent (состояние `catched`),
система SHALL в одной write-транзакции сохранить байты `.torrent`,
привязав их к этой загрузке, и сменить её `source_type` на `torrent`. Тем
самым воркер добавит раздачу файлом, а не magnet-хешем (иначе на закрытом
трекере без DHT метаданные не докачаются, а magnet застрянет в metaDL →
failed). Апгрейд SHALL применяться ТОЛЬКО пока загрузка в `catched` (воркер
источник ещё не добавил); для уже добавленной (`downloading` и далее)
загрузки смена `source_type` при дедупе выполняться SHALL NOT — её судьба
решается путями retry/сверки, а не приёмом. Апгрейд SHALL быть best-effort:
его неуспех приём не прерывает.
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
из полей magnet (пользовательский текст первым; при пустом тексте — только
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
Синтез SHALL выполняться без сетевых запросов.
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
NOT.
#### Scenario: Быстрый приём .torrent-файла
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
- **WHEN** вызывается приём
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
`catched` (`source_type = torrent`) с записями `download_infohash`
- **AND** байты файла сохраняются привязанными к загрузке
- **AND** ответ транспорту отдан без обращения к qBittorrent
#### Scenario: Инфохэш из исходных байтов info
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
#### Scenario: v2-only файл записывается под v2-хешем
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
qBittorrent
- **AND** сопоставление раздачи работает по нему
#### Scenario: Дубль .torrent по активной torrent-задаче
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash и
`source_type = torrent`
- **WHEN** принимается `.torrent` с тем же инфохэшем
- **THEN** новая загрузка не создаётся, возвращается существующая
- **AND** байты торрента повторно не сохраняются (дубль)
#### Scenario: Апгрейд catched-magnet до torrent
- **GIVEN** активная загрузка в `catched` с `source_type = magnet` и хешем `h`
(magnet-задача ещё не отдана в qBittorrent)
- **WHEN** принимается `.torrent` с тем же инфохэшем `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
- **AND** байты `.torrent` сохраняются привязанными к ней, а её `source_type`
становится `torrent` — в одной транзакции
- **AND** воркер добавит раздачу файлом (не по magnet)
#### Scenario: Magnet-задача уже добавлена — апгрейда нет
- **GIVEN** активная загрузка с `source_type = magnet` уже в `downloading`
(отдана в qBittorrent)
- **WHEN** принимается `.torrent` с тем же инфохэшем
- **THEN** возвращается существующая загрузка, её `source_type` остаётся
`magnet`, байты `.torrent` не сохраняются
#### Scenario: Контекст из полей файла
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
файлам, домен трекера), дополняющий текст транспорта
- **AND** синтез выполнен без сетевых запросов
#### Scenario: Слишком большой .torrent отклоняется
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
@@ -0,0 +1,29 @@
## 1. F1 — пер-хеш гард дедуп-дозаписи
- [x] 1.1 В `CreateDownloadIfNoActive` (`internal/store/download.go`) заменить
безусловный цикл `INSERT OR IGNORE` дедуп-ветки на пер-хеш гард как в
`AddInfohashes`: пропускать хеш, которым владеет другая активная задача
(`findActiveByInfohash(..., existing.ID)`), внутри уже открытой tx.
- [x] 1.2 Тест в `internal/store`: гибридный дедуп на B не крадёт хеш,
принадлежащий активной A (инвариант сохранён).
## 2. F6 — апгрейд catched-magnet до torrent
- [x] 2.1 Добавить guarded-метод хранилища
`UpgradeCatchedMagnetToTorrent(ctx, downloadID, torrentBlob) (bool, error)`:
в одной tx гардированным UPDATE `source_type='torrent'` при
`source_type='magnet' AND state='catched'`, затем сохранить байты в
`download_torrent`; вернуть, был ли апгрейд.
- [x] 2.2 В `internal/ingest/ingest.go` свести оба дедуп-пути к `attached()` и
вызвать апгрейд, когда входящий источник — torrent с байтами
(best-effort: неуспех логируется, приём не валится).
- [x] 2.3 Обновить интерфейс `ingest.Store` и `fakeStore` в тестах.
- [x] 2.4 Тесты в `internal/store`: апгрейд из `catched`+magnet сохраняет байты
и меняет `source_type`; из `downloading`/не-magnet — no-op. Тест в
`internal/ingest`: дедуп `.torrent` на catched-magnet вызывает апгрейд.
## 3. Проверки
- [x] 3.1 `openspec validate --strict ingest-dedup-integrity`
- [x] 3.2 `task test`
- [x] 3.3 `task lint`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,72 @@
# Design
## Контекст
`linking` — короткое рабочее состояние между «решили раскладывать» и
«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его
никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей
сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking`
не был устойчивым владельцем шага.
## Решение 1: `transition` возвращает ошибку — но только там, где она нужна
Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/
`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах
(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты
слияния и шум. Вместо этого:
- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие
вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`,
sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи
claim, — переход и есть конец шага.
- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return
error`. Пинги/скан живут в ней (обёртка делегирует).
**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и
конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран
точечный вариант: минимальный след, локальные правки по функциям, ошибка
возвращается ровно там, где за claim следует побочный эффект.
Использование: `Apply` и авто-раскладка в `finishRecognition` зовут
`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале
claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing`
(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется.
## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking`
Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой
(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до
того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный
`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных
ссылках, а `CreateFileLinks` допишет учёт.
**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение
через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил
бы в восстановление сверкой, которое к этому кейсу не относится.
Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим
до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон
починит следующий тик сверки.
## Решение 3: sweep осиротевшего `linking` на тике и старте
Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла —
это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review`
(ребро уже в графе) с кодом `interrupted`.
**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой
срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая
`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ
в полёте — она осталась после краша между claim и финальным переходом. Ложных
срабатываний на живой раскладке нет.
Так `linking` получает владельца на рестарте/тике — по образцу `recognizing`
(`recognizePending`); инвариант «у каждого нетерминального состояния есть
владелец» восстановлен.
## Что НЕ делаем
- Не трогаем граф переходов: ребро `linking → review` уже объявлено в
`allowedTransitions`.
- Не меняем схему БД.
- Не меняем сигнатуру `transition` глобально (см. Решение 1).
@@ -0,0 +1,61 @@
## Why
Раскладка хардлинками устроена как «claim-then-side-effect»: сначала задача
переводится в `linking` (claim владения шагом), затем создаются хардлинки и
пишется их учёт (`file_link`). Ревью жизненного цикла (Fable, 2026-07-08)
нашло две связанные дыры устойчивости этого пути.
- **MINOR-7:** `worker.transition` при ошибке записи состояния логировал её, но
НЕ возвращал вызывающему. На путях `Apply` и авто-раскладки в
`finishRecognition` выполнение продолжалось к побочным эффектам: хардлинки
создавались, пока claim перехода в `linking` не закоммичен. Финальный переход
`linking → done` оценивался графом как `review → done` (нелегальное ребро) и
отклонялся — задача застревала в `review` со stale-планом, скан/уведомление не
срабатывали.
- **MAJOR-4:** задача может осиротеть в `linking`:
(A) без краха — хардлинки созданы, но `CreateFileLinks` упал транзиентно
(SQLite busy) → голый `return` оставлял задачу в `linking`, а файлы на диске —
без строк `file_link`;
(B) краш процесса между переходом в `linking` и финальным переходом → на
рестарте `linking` не листит НИКТО (поллинг листит `downloading`, распознавание
`completed`/`recognizing`, сверка — `done`/`target_missing`/`orphaned`,
восстановление — `failed`/`stuck`). Задача сидит в `linking` вечно; выход —
только ручной Cancel/Defer (недискаверабельно). Нарушен инвариант «у каждого
нетерминального состояния есть владелец» (`recognizing` уже лечится
рестартом через `recognizePending`, `linking` — нет).
## What Changes
- `transition` разделяется на fire-and-forget обёртку (прежнее имя, прежнее
поведение для reconcile/таймаутов/финальных переходов) и `transitionErr`,
которая ВОЗВРАЩАЕТ ошибку записи. На claim-then-side-effect путях (`Apply`,
авто-раскладка в `finishRecognition`) провал claim перехода в `linking` теперь
прерывает выполнение ДО хардлинков.
- В `linkPlan` провал `CreateFileLinks` больше не оставляет задачу в `linking`:
задача уходит в `review` с кодом `persist` и причиной; повторный `Apply`
идемпотентен (хардлинки уже на диске → `StatusExists`, учёт дописывается).
- Новый шаг поллинга `sweepLinking`: на каждом тике и на старте задачи в
`linking` возвращаются в `review` с кодом `interrupted` и причиной
«прерванная раскладка, повтори применение». Любая `linking`, видимая под
`w.mu`, устарела по построению (активная раскладка держит `w.mu` весь свой
срок), значит осталась после краха.
## Capabilities
### Modified Capabilities
- `file-layout`: раскладка становится устойчивой к сбою записи claim/учёта —
хардлинки не создаются при незакоммиченном claim, а сбой записи учёта не
стрэндит задачу в `linking`.
- `state-reconciliation`: у нетерминального `linking` появляется владелец на
рестарте/тике — sweep осиротевших `linking` в `review`.
## Impact
- **Код:** `internal/worker/worker.go` (`transition`/`transitionErr`, `pollOnce`,
`sweepLinking`), `internal/worker/review.go` (`Apply`, `finishRecognition`,
`linkPlan`). Граф переходов (`internal/store/download.go`) правки не требует —
ребро `linking → review` уже объявлено.
- **Тесты:** провал claim прерывает до хардлинков; провал `CreateFileLinks`
уводит в `review` (файлы на диске); sweep осиротевшего `linking``review`.
- **БД/схема:** без изменений.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта
Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход
задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки.
Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать
хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии
(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) —
чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim
(иначе финальный переход `linking → done` из фактического состояния был бы
отклонён графом, и задача застряла бы со stale-планом).
Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась
(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система
SHALL перевести её в `review` с причиной. Повторное применение SHALL быть
идемпотентным — уже созданные хардлинки распознаются как существующие
(`StatusExists`), а их учёт дописывается.
#### Scenario: Провал claim не создаёт хардлинков
- **GIVEN** задача в `review` с готовым источником и валидным планом
- **WHEN** запись перехода в `linking` проваливается
- **THEN** хардлинки не создаются, учёт `file_link` не пишется
- **AND** задача остаётся в `review`, а команда отказывает с ошибкой
#### Scenario: Провал учёта уводит в review, не оставляя в linking
- **GIVEN** хардлинки по плану уже созданы на файловой системе
- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой
- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся
в `linking`
- **AND** созданные хардлинки остаются на диске
- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done`
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Восстановление задачи, застрявшей в linking
Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код
`interrupted`), откуда человек повторит применение (повтор идемпотентен).
`linking` — нетерминальное активное состояние, и у него, как у каждого
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
краш процесса между переходом в `linking` и финальным переходом оставил бы
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`).
Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
активная раскладка удерживает блокировку весь свой срок и завершает переход из
`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
SHALL задевать раскладку в полёте.
#### Scenario: Осиротевший linking возвращается в review
- **GIVEN** задача осталась в `linking` после краха между claim и финальным
переходом
- **WHEN** выполняется тик поллинга (или старт сервиса)
- **THEN** задача переходит в `review` с причиной «прерванная раскладка»
(код `interrupted`)
- **AND** её можно повторно применить из ревью
#### Scenario: Прочие состояния sweep не задевает
- **GIVEN** задачи в состояниях `done` и `review`
- **WHEN** выполняется тик поллинга
- **THEN** восстановление `linking` их состояние не меняет
@@ -0,0 +1,34 @@
## 1. Возврат ошибки перехода (MINOR-7)
- [x] 1.1 Разделить `transition` на `void`-обёртку и `transitionErr`
(возвращает ошибку записи); пинги/скан — в `transitionErr`
- [x] 1.2 `Apply`: заменить claim `transition(StateLinking)` на
`transitionErr` с прерыванием до `linkPlan` при ошибке
- [x] 1.3 `finishRecognition` (авто-раскладка): то же — при провале claim
остаёмся в `recognizing`, `linkPlan` не зовём
## 2. Провал учёта не оставляет в linking (MAJOR-4 A)
- [x] 2.1 В `linkPlan` при провале `CreateFileLinks` перевести задачу в
`review` (код `persist`) вместо голого `return`
## 3. Sweep осиротевшего linking (MAJOR-4 B)
- [x] 3.1 Добавить `sweepLinking`: под `w.mu` листить `linking` и переводить
`linking → review` (код `interrupted`, причина «прерванная раскладка,
повтори применение»)
- [x] 3.2 Вызвать `sweepLinking` в `pollOnce` (тик + старт)
## 4. Тесты
- [x] 4.1 Провал claim перехода в `linking` прерывает `Apply` до хардлинков
(нет файлов на диске, нет `file_link`, состояние `review`)
- [x] 4.2 Провал `CreateFileLinks` уводит в `review` (файлы на диске есть,
код `persist`)
- [x] 4.3 `sweepLinking` переводит осиротевший `linking` в `review`
(код `interrupted`); прочие состояния не задевает
## 5. Проверки
- [x] 5.1 `task test` и `task lint` проходят
- [x] 5.2 `openspec validate linking-transition-robustness --strict` проходит
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-08
@@ -0,0 +1,104 @@
# Design: retry/stall basis
## Контекст
Два таймаута зависания в `Worker.checkTimeouts` сегодня используют один базис —
возраст торрента `now added_on`:
- `magnet_timeout`: `metaDL` дольше порога → `failed/magnet_timeout`.
- `stuck_after`: `stalledDL` дольше порога → `stuck/stalled`.
Для `magnet_timeout` возраст семантически верен (сколько торрент вообще висит
без метаданных). Для `stuck_after` возраст НЕВЕРЕН: нас интересует **простой**
(сколько данные не двигаются), а не общий возраст (MAJOR-2). Отдельно retry
живого торрента не сбрасывает базис, и задача мгновенно снова падает (MAJOR-1).
## Дизайн-развилка: откуда брать базис простоя/таймаута
Ключевое решение change — где взять базис для двух мер. Рассмотрены варианты:
- **(a) Новые колонки БД** `retried_at` и/или `stalled_since`. Базис возраста =
`max(added_on, retried_at)`; простой — от `stalled_since` (момент входа в
`stalledDL`, который мы сами детектируем и пишем/сбрасываем на каждом тике).
- **(b) Переиспользовать `last_activity` из снимка торрента** для измерения
простоя, без колонки на stall.
- **(c) Гибрид (ВЫБРАН):** колонка `retried_at` (только для сброса базиса при
ручном retry) + `last_activity` qBittorrent (для измерения простоя). Колонки
`stalled_since` НЕТ.
### Выбор: (c) `retried_at` (БД) + `last_activity` (qBit)
**Простой мерим по `last_activity`, а не по `stalled_since`-колонке.**
qBittorrent уже отдаёт `last_activity` (Unix-время последнего движения данных)
в том же ответе `/torrents/info` — это авторитетный источник «сколько простой»
прямо из движка. `stalled_since` дублировал бы это состояние, требовал бы
детектировать переход «вход в stalledDL», писать/сбрасывать колонку на КАЖДОМ
тике (торренты мерцают `stalledDL``downloading`) и рисковал бы разъездом с
собственным взглядом qBittorrent. Простой = `now last_activity`: торрент,
двигавший данные секунду назад, простаивает ~0 несмотря на возраст 5ч →
MAJOR-2 закрыт без схемы для stall. Это часть варианта (b).
**Сброс базиса при retry храним в `retried_at` (БД), а не в памяти.** Спека
требует, чтобы retry давал свежее окно и задача не падала снова. `last_activity`
этого не выражает: у по-настоящему простаивающего торрента она «часы назад», и
возврат в `downloading` тут же дал бы `stuck` на следующем тике. Нужна
персистентная метка «пользователь нажал retry в момент T», которая приподнимает
пол ОБОИХ базисов: `basis = max(добавление|last_activity, retried_at)`. Она
должна пережить интервал поллинга и рестарт процесса (retry, затем рестарт не
должен ронять задачу), поэтому — колонка, а не in-memory map. Это часть варианта
(a), но минимальная: одна nullable TEXT-колонка. Закрывает MAJOR-1.
### Почему не чистые (a) или (b)
- **Чистый (b) без колонки** — нельзя записать `last_activity` qBittorrent, так
что retry не смог бы сдвинуть базис → MAJOR-1 не решается.
- **Чистый (a) со `stalled_since`** — лишняя колонка + пер-тиковая
бухгалтерия входа/выхода из `stalledDL`, дублирующая `last_activity`. Отвергнут
на минимальности схемы и единственном источнике истины.
### Замечание об интеграции (точка человеческого вето)
Change читает НОВОЕ поле qBittorrent `last_activity`, но НЕ добавляет нового
вызова API или интеграционной поверхности — поле уже приходит в ответе
`/torrents/info`, парсим на одно поле больше. Это единственная «интеграция»
change, и она безопасна. Более глубокая интеграция для полного решения NIT-12
(см. ниже) СОЗНАТЕЛЬНО отложена как точка человеческого вето.
## Итоговая схема базисов
```
magnetAge = now max( added_on | created_at(fallback), retried_at ) // magnet_timeout
stallIdle = now max( last_activity | added_on|created_at(fallback), retried_at ) // stuck_after
```
- `addedBasis(d,t)``added_on`, иначе `created_at` (NIT-10), иначе базис
неизвестен (WARN, таймаут не срабатывает).
- `retriedFloor(d,basis)` — приподнимает базис до `retried_at`, если он позже.
- `retried_at` не чистится: как только данные двинулись, `last_activity`
естественно обгоняет `retried_at`, и пол перестаёт влиять.
## NIT-12: retry сломанного живого торрента
Живой торрент в `error`/`missingFiles` (класс `classErrored`) — перецепка к
нему бессмысленна: reconcile на ближайшем тике вернёт задачу в `failed`. Retry
теперь считает такой торрент «неживым для целей перецепки» и идёт по ветке
повторного `Add` (повторно отдаёт источник). Это честнее слепой перецепки:
retry перецепляется только к ЗДОРОВОМУ живому торренту.
**Остаточное ограничение (отложено, точка вето):** для устойчиво сломанного
торрента повторный `Add` того же infohash qBittorrent, как правило, дедуплицирует
— ошибка не очистится, и следующий тик всё равно вернёт задачу в `failed`. Полное
устранение (принудительный recheck / delete+re-add через qBittorrent) требует
НОВОЙ интеграции с клиентом и вынесено за рамки change на человеческое решение.
## Тесты
- `TestRetryResetsTimeoutBasis` — MAJOR-1: retry живого stalledDL-торрента с
давним `added_on` и давним `last_activity`, затем СЛЕДУЮЩИЙ тик Poll →
остаётся `downloading` (без сброса базиса ушёл бы в `stuck`). Именно эту
регрессию прячет `TestRetryReattachesNoReadd`.
- `TestStallMeasuredFromLastActivity` — MAJOR-2: `stalledDL` с давним `added_on`,
но свежим `last_activity``downloading`; контроль — давняя `last_activity`
`stuck`.
- `TestSetRetriedAtOverwrites` — store: `retried_at` перезаписывается (в отличие
от однократного `source_added_at`).
@@ -0,0 +1,78 @@
## Why
Два связанных бага в семантике таймаутов зависания и ручного retry делают
повседневные сценарии сломанными:
- **MAJOR-1 — retry живого торрента мгновенно снова падает.** `Worker.Retry`
при живой раздаче (`alive=true`) не переиздаёт `Add`, а лишь возвращает
задачу в `downloading`. Базис отсчёта таймаута (`age = now added_on`) при
этом НЕ сбрасывается. Если торрент давно добавлен/давно простаивает,
ближайший тик снова видит `stalledDL && age > stuck_after` → задача опять
уходит в `stuck` (~секунды). Спека `state-reconciliation` «Ручной повтор»
требует сброса базиса, но код его не выполняет (комментарий «базис от
added_on» верен лишь для ветки повторного `Add`). Существующий тест
`TestRetryReattachesNoReadd` прячет баг, ставя `added_on` «минуту назад».
- **MAJOR-2 — `stuck_after` мерит ВОЗРАСТ, а не ПРОСТОЙ.** `checkTimeouts`
считает `stalledDL`-таймаут от `added_on` (возраст торрента). Торрент,
качавшийся 5 часов и на один тик зашедший в `stalledDL` (нормальный проход
между пирами), мгновенно получает `stuck` со лживым сообщением «stalled for
5h» и уведомление `EventFailed`. Результат — флап `stuck``downloading` и
до-часовые ложные пинги. Спека сама противоречива: «`stalledDL` дольше
`stuck_after`» (простой) против «возраст от `added_on`».
Дополнительно закрываются два NIT из того же ревью:
- **NIT-10** — фолбэк базиса возраста `added_on → created_at` (когда qBit не
отдал `added_on`) остаётся, но теперь явно документирован и покрыт.
- **NIT-12** — retry задачи в `qbit_error` мгновенно откатывается: перецепка к
сломанному (`error`/`missingFiles`) живому торренту бессмысленна — reconcile
тут же возвращает задачу в `failed`. Retry перестаёт перецепляться к
сломанному торренту и повторно отдаёт источник.
## What Changes
- **Мера простоя вместо возраста для `stuck_after`.** `stalledDL`-таймаут
считается от `last_activity` qBittorrent (момент последнего движения данных),
а не от возраста торрента. Долго качавшийся торрент со свежей активностью в
`stuck` не уходит (MAJOR-2). `magnet_timeout` по-прежнему мерит **возраст**
(`metaDL` без метаданных) от `added_on` — это семантически верно.
- **Сброс базиса таймаутов при ручном retry.** Новая колонка `download.retried_at`
(RFC 3339 UTC) фиксирует момент retry и приподнимает базис ОБОИХ таймаутов
(`max(базис, retried_at)`). После retry задача получает свежее окно и не
падает снова на ближайшем тике (MAJOR-1). Хранится в БД (не в памяти), чтобы
сброс пережил интервал поллинга и рестарт процесса.
- **Retry не перецепляется к сломанному торренту.** Если живой торрент в
состоянии ошибки qBittorrent (`error`/`missingFiles`), retry повторно отдаёт
источник вместо перецепки (NIT-12).
## Capabilities
### New Capabilities
Нет. Семантика таймаутов зависания относится к жизненному циклу загрузки,
который пока живёт в `docs/specs/workflow.md` (не мигрирован в OpenSpec).
Нормативная правка `stuck_after`/`magnet_timeout` вносится туда; в OpenSpec
затрагивается только `state-reconciliation` (восстановление и ручной retry).
### Modified Capabilities
- `state-reconciliation`: уточняется, что предотвращение `stuck` для
прогрессирующего торрента опирается на **простой от `last_activity`**, а не
на возраст; ручной retry сбрасывает базис таймаутов через `retried_at` и не
перецепляется к сломанному живому торренту.
## Impact
- **Спеки:** дельта `state-reconciliation` (2 MODIFIED requirements);
правка семантики таймаутов и retry в `docs/specs/workflow.md` (источник
истины по жизненному циклу до миграции).
- **Код:** `internal/worker/worker.go``checkTimeouts` (две разные меры),
`torrentAge`/новые `stallDuration`/`addedBasis`/`retriedFloor`, `Retry`
(сброс базиса + перецепка только к здоровому торренту); `internal/qbt`
(поле `last_activity`); `internal/store/download.go` (`RetriedAt`,
`RetriedTime`, `SetRetriedAt`).
- **Миграции БД:** `0010_retried_at.sql` — колонка `download.retried_at`;
обновление ER-схемы `docs/specs/database.md`.
- **qBittorrent-клиент:** читается новое поле `last_activity` из того же
ответа `/torrents/info` (без нового вызова API).

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