Compare commits

...
9 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 50f29b56aa Беклог + чистка: две находки аудита в беклог, поправлен устаревший комментарий
Аудит capability после пачки lifecycle-задач вскрыл две пред-существующие
находки (вне scope самих задач) — заведены в беклог:
- catched-source-type-namer-okno (средний): addReq не пересобирается из свежего
  source_type в окне namer'а; самоисцеляется через magnet_timeout→Retry.
- dismiss-cancel-user-dismiss-marker (низкий): веб-UI зовёт Cancel вместо Dismiss
  на не-терминальных, теряется маркер user_dismiss.

Инлайн: finishRecognition — комментарий врал про «Ф3, авто-раскладки нет»;
фактически авто-раскладка идёт при Decision.Auto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 08:44:36 +03:00
av 659fa5ec44 Слияние: уборка торрента при cancel во время add (T4)
# Conflicts:
#	docs/backlog/README.md
2026-07-17 22:11:53 +03:00
avandClaude Opus 4.8 3a00fde058 Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)
Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит
имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без
задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже
качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат
отменённой задаче). Спека покрывала переход состояния, но не побочный эффект.

Комбинированная защита в processCatched:
- re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не
  добавляется вовсе (сужает окно гонки);
- свежий листинг перед add подтверждает отсутствие infohash — признак «своего»
  торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик);
- при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом
  state != catched) — уборка добавленного нами торрента qbt.Delete(_, true);
- WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов.

Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после
подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой
торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование
по инварианту «источник неприкосновенен» — в design.md изменения.

Дельта — download-tracking (требование «Добавление пойманной загрузки в
qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене,
негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после
add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не
добавляет).

Change archived: 2026-07-17-cancel-during-add-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:10:21 +03:00
av d02d88f49a Слияние: Defer отклоняет catched, снято мёртвое ребро (T3) 2026-07-17 21:39:41 +03:00
avandClaude Opus 4.8 098695011f Жизненный цикл: Defer запрещён из пре-источникового catched (MAJOR-6)
Команда Defer гардила только IsTerminal() и потому принимала catched
(торрент ещё не добавлен в qBittorrent). Defer из catched уводил задачу
в лимбо → необратимый deleted: processCatched листает только catched и
больше её не подхватывал, а последующие команды через отсутствие
источника выводили deleted (ноль исходящих рёбер), хотя байты .torrent
лежат в download_torrent.

- Worker.Defer отклоняет catched с ErrConflict (транслируется в 409 /
  редирект с сообщением); прочие не-терминальные состояния, где раздача
  уже есть, принимает как раньше.
- Снято мёртвое ребро графа catched → deferred (allowedTransitions);
  инвариант «deferred из каждого не-терминального» уточнён: кроме
  пре-источникового catched. catched — единственное состояние без
  раздачи среди не-терминальных.
- Тесты: Defer из catched отклоняется и не меняет состояние; инвариант
  графа обновлён + негативная проверка ребра.
- OpenSpec: MODIFIED «Команды ревью и их эффекты» (review) с позитивным
  и негативным сценариями; change заархивирован, дельта влита в спеку.
- Беклог: закрыта review-major6-defer-catched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:39:01 +03:00
av 91de53b8f1 Слияние: режиссёр в карточке загрузки (T2)
# Conflicts:
#	docs/backlog/README.md
2026-07-17 21:22:35 +03:00
av 0ced0e18a5 Слияние: Jellyfin-рескан после reverted/deleted (T1) 2026-07-17 21:22:13 +03:00
avandClaude Opus 4.8 5948f4d219 Веб-UI: режиссёр в блоке «Распознано как» на странице загрузки
На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран
ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в
неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели
только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же
на экране ревью.

Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan)
LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде),
выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name
побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки
и экран ревью берут режиссёра из той же функции — согласованно с заголовком.

OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»),
review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое
разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта
rezhisser-v-kartochke-zagruzki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:21:27 +03:00
avandClaude Opus 4.8 1639ebfdd7 Пересканирование Jellyfin: расширить триггер на reverted и deleted
Скан Jellyfin (POST /Library/Refresh) слался только при входе в done.
После Undo (reverted) и Delete (deleted) наши хардлинки сняты, а Jellyfin
держал битые записи до скана по расписанию.

Гейт скана в едином чекпоинте transitionErr переведён с state == done на
предикат triggersScan(state) по множеству {done, reverted, deleted}: гейт по
состоянию-цели естественно ловит пользовательские Undo/Delete и
reconcile-производный deleted, идемпотентно. target_missing/orphaned —
промежуточный рассинхрон (ждём relink/лечения) — исключены.

OpenSpec: заведена и влита дельта file-layout (требование
«Пересканирование Jellyfin после изменения библиотечных ссылок»); change
архивирован. Синк рукописных доков architecture.md/workflow.md. Тесты:
скан стреляет на reverted и deleted, молчит на входе вне множества.
Закрыта задача беклога jellyfin-skan-posle-udaleniya.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:14:18 +03:00
44 changed files with 2124 additions and 220 deletions
+2 -4
View File
@@ -30,15 +30,12 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи) - [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт… - [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture - [Бэкап SQLite](backup-sqlite.md) — architecture
- [Сигнал Jellyfin после отката и удаления файлов](jellyfin-skan-posle-udaleniya.md) — Скан шлётся только на done; после reverted/deleted Jellyfin держит битые записи. Клиент и гейт готовы, но скана нет в openspec-спеках
- [Режиссёр в блоке «Распознано как» на странице загрузки](rezhisser-v-kartochke-zagruzki.md) — На /download/{id} режиссёр всегда прочерк (поля нет в шаблоне); экран ревью его уже выводит
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис - [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг) - [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать… - [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку - [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags - [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_ - [`addReq` не пересобирается из свежего `source_type` перед Add (окно namer'а)](catched-source-type-namer-okno.md) — При апгрейде magnet→.torrent в окне namer'а добавится magnet из устаревшего снимка; самоисцеляется через magnet_timeout→failed→Retry _(аудит 2026-07-17)_
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
## Низкий ## Низкий
@@ -60,3 +57,4 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое… - [Современный Web-UI как PWA](web-ui-pwa.md) — Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое, отзывчивое…
- [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_ - [Идентичность инфохэшей: split v1/v2 одного торрента + крафт-магнет отравляет владение (F4, F5)](review-f4-f5-infohash-identity.md) — split v1/v2 идентичность и крафт-магнет отравляют владение инфохэшами _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_ - [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
- [Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`](dismiss-cancel-user-dismiss-marker.md) — Функционально ок (Cancel даёт cancelled), но маркер user_dismiss в error_code теряется; расхождение с буквой спеки _(аудит 2026-07-17)_
@@ -0,0 +1,58 @@
# `addReq` не пересобирается из свежего `source_type` перед `Add` (окно namer'а)
**Приоритет:** средний · **Теги:** review-2026-07-17, lifecycle
Найдено аудитом capability **download-tracking** (сверка код↔спека после пачки
lifecycle-задач). Пред-существующее, вне scope задачи F3/cancel-cleanup — T4
осознанно вынес это за рамки и задокументировал в своём design.md.
## Суть
`processCatched` (`internal/worker/worker.go:442-517`) строит `addReq` из записи
`cur`, перечитанной под замком на `:447-455`, **до** вызова namer'а (LLM, секунды,
вне замка, `:463-478`). Затем на `:484-491` под замком перечитывается `before`,
но `addReq` из него **не пересобирается** — проверяется только `state == catched`.
Если апгрейд пойманной magnet-задачи до `.torrent`
(`UpgradeCatchedMagnetToTorrent`, `internal/store/download.go:392`) отработает
именно в окне namer'а (приём принял `.torrent` с тем же infohash, `state`
остаётся `catched`), воркер добавит **magnet-ссылку из устаревшего снимка**, хотя
в БД уже `source_type=torrent`.
Спека (`openspec/specs/download-tracking/spec.md`, раздел про добавление по
`source_type`) требует перечитывать `source_type` **под блокировкой переходов
непосредственно перед добавлением** — сейчас это требование в окне namer'а
нарушается.
## Насколько больно
Ограниченно и самоисцеляемо: на закрытом трекере magnet без метаданных зависнет
в `metaDL` → предохранитель `magnet_timeout``failed`; ручной `Retry`
перечитает актуальный `source_type` и добьёт. Данные не страдают, инвариант
«источник неприкосновенен» не задет. Окно узкое (апгрейд должен лечь ровно в
LLM-вызов по тому же infohash). Поэтому средний, не высокий.
## Развилка (решить до кода)
- **A — ужесточить код (соответствие букве спеки, закрыть окно):** после re-read
`before` под замком (`:484`) пересобирать `addReq`/`hint` из `before`, если
`source_type` изменился. Нюанс: `sourceAddParts` читает байты `.torrent` — это
тяжёлый вызов, держать под замком нельзя (спека: тяжёлое — вне блокировки), плюс
подсказка имени для `.torrent` иная (метаданные раздачи vs имя из magnet), т.е.
при апгрейде корректно был бы и повторный namer. Не однострочник.
- **B — смягчить спеку (принять реальность):** признать, что рациональ («не
полагаться на снимок, снятый ранее вне блокировки») уже выполнен первым re-read
под замком на `:447`, и переформулировать требование как «перечитывать
`source_type` под блокировкой после тик-снимка», явно приняв узкое namer-окно
как самоисцеляемое через `Retry`.
Рекомендация — начать с B (дёшево, отражает фактическое осознанное поведение), A
завести только если узкое окно окажется реальной болью в эксплуатации.
## Ссылки
- `internal/worker/worker.go:442-517``processCatched`
- `internal/store/download.go:392``UpgradeCatchedMagnetToTorrent`
- `openspec/specs/download-tracking/spec.md` — требование про `source_type`
- Тест `TestProcessCatchedReReadsSourceTypeUnderLock` покрывает апгрейд между
тик-снимком и re-read, но **не** окно namer'а.
@@ -0,0 +1,47 @@
# Веб-UI зовёт Cancel вместо Dismiss на не-терминальных → теряется `user_dismiss`
**Приоритет:** низкий · **Теги:** review-2026-07-17, state-reconciliation
Найдено аудитом capability **state-reconciliation** (сверка код↔спека).
Пред-существующее, вне scope пачки lifecycle-задач.
## Суть
Спека `openspec/specs/state-reconciliation/spec.md` (требование «Ручное
закрытие»): команда `dismiss` доступна из **любого** состояния кроме `deleted`
**во всех транспортах**, и переход SHALL помечаться `error_code = user_dismiss`.
В веб-UI danger-zone «Закрыть» (dismiss) гейтится только для терминальных
состояний: `Dismissable = IsTerminal() && !deleted && !cancelled`
(`internal/httpapi/download.go:130`, шаблон
`web/templates/partials/download_main.html:99-112`). Для НЕ-терминальных
(`stuck`, `deferred`, `downloading`, `review`) закрытие в UI идёт кнопкой
«Отменить» → `Cancel` (`internal/worker/worker.go:973`), которая пишет **пустой**
`error_code`, а не `user_dismiss`.
## Насколько больно
Функционально сценарии проходят: `Cancel` тоже даёт `cancelled` и не трогает
файлы/раздачу, семантика для пользователя идентична. Состояния без доступного
«закрытия» нет (кроме `deleted`/`cancelled`). Теряется только маркер
`user_dismiss` в `error_code` — расхождение с буквой спеки и небольшая потеря
наблюдаемости (в аналитике/логах не отличить «пользователь закрыл активную» от
«пользователь отменил»). Отсюда низкий приоритет.
## Развилка (решить до кода)
- **A — привести код к спеке:** веб-UI на не-терминальных тоже зовёт `Dismiss`
ради единого маркера `user_dismiss`; либо `Cancel` пишет `user_dismiss`.
- **B — привести спеку к коду:** зафиксировать осознанное разделение (`Cancel`
для активных, `Dismiss` для терминальных) — уточнить требование, что стоп-кран
на не-терминальных реализуется `Cancel`'ом, и определить, какой `error_code`
ожидается.
Сначала решить, осознанно ли разделение Cancel/Dismiss; если да — вероятно B.
## Ссылки
- `internal/httpapi/download.go:130` — гейт `Dismissable`
- `web/templates/partials/download_main.html:99-112` — danger-zone
- `internal/worker/worker.go:973``Cancel`; `:1001``Dismiss`
- `openspec/specs/state-reconciliation/spec.md` — требование «Ручное закрытие»
@@ -1,59 +0,0 @@
# Сигнал Jellyfin после отката и удаления файлов
**Приоритет:** средний
Сейчас пересканирование Jellyfin шлётся **только** при входе в `done`. После
отката (`reverted`) или удаления (`deleted`) хардлинки сняты, а Jellyfin
продолжает показывать записи с битыми путями до следующего скана по расписанию.
Интеграция уже есть целиком, клиент писать не надо:
- `internal/jellyfin/jellyfin.go` — клиент, `RefreshLibraries()` (`:76`) →
`POST /Library/Refresh`. Тесты — `internal/jellyfin/jellyfin_test.go`.
- Конфиг: `internal/config/config.go:99-107` (`Jellyfin{Enabled,URL,APIKey,Proxy,Timeout}`),
валидация `:305-308`, пример `config.example.toml:59-64`.
- Проводка: `cmd/jellybit/serve.go:137-152``wrk.SetScanner(jf)`; интерфейс
`internal/worker/worker.go:171-176`.
Точка правки — гейт в `transitionErr` (`internal/worker/worker.go:846-852`):
`if w.scanner != nil && state == store.StateDone`. Оба пути удаления уже проходят
через ту же `transition`: `Undo()` (`internal/worker/review.go:535``:574`) и
`Delete()` (`:601``:657`). То есть база правки = расширить условие; фоновый
ctx, `capFileLayout`-скоуп и неблокирующая горутина переиспользуются как есть.
## Что учесть
1. **`StateDeleted` приходит не только от пользователя.**
`internal/worker/reconcile.go:30-41` `deriveState()` возвращает `StateDeleted`
при `!sourcePresent && !targetPresent` (авто-сверка). Скан там формально уместен,
но это уже не «после удаления файлов нами». Решить: гейтить по состоянию (просто,
ловит и reconcile) или по факту снятия ссылок (точнее — в `Undo`/`Delete` есть
счётчик снятого, но тогда триггер уезжает из единого чекпоинта `transitionErr`).
2. **`StateTargetMissing`** (`reconcile.go:35`) — цель пропала мимо нас. Кандидат
по той же логике, надо явно решить, входит или нет.
3. **`Dismiss` идёт мимо чекпоинта:** `internal/worker/worker.go:944` пишет
состояние напрямую через `w.store.SetDownloadState`, минуя `transitionErr`. Для
dismiss это корректно (файлы не трогаются), но если вешать скан на `cancelled`
не сработает.
4. `Delete()` при ошибке qBittorrent (`internal/worker/review.go:650-652`)
возвращается **до** `transition` → ссылки сняты, скана не будет. Идемпотентный
повтор дожмёт, но окно рассинхрона есть.
5. Порядок верный: `layouter.Undo` отрабатывает до `transition`, так что скан
увидит уже снятые ссылки.
## Спеки — здесь дыра
Про Jellyfin-скан в `openspec/specs/` **нет ни слова** (грепом
`Library/Refresh|RefreshLibraries|пересканир` — ноль попаданий). Живёт только в
рукописных доках: `docs/specs/architecture.md:182-196` («Пересканирование
Jellyfin», прямо сказано «После успешной раскладки (вход в `done`)»),
`docs/specs/workflow.md:100-102`.
→ Задача тянет дельту в `file-layout` (пакет `jellyfin` отнесён к этой capability —
`architecture.md:44`, код скоупится `capFileLayout`) + правку
`docs/specs/architecture.md:182` и `docs/specs/workflow.md:100`, где формулировка
«при входе в done» станет неверной.
Тесты: `internal/worker/review_test.go:88` `TestScanner_FiresOnDone` +
`recordingScanner` (`:81-86`). Негативных тестов «не стреляет на других состояниях»
нет → расширение безопасно, но тесты на `reverted`/`deleted` надо дописать.
@@ -1,11 +0,0 @@
# Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (оба ревьюера: F3 + NIT-13). worker.go:368-390, discover.go:43-50.
Сценарий: задача catched, worker вне w.mu выводит имя (LLM, секунды) + qbt.Add (успех). Параллельно user Cancel (catched→cancelled). PromoteCatched корректно пропускает (гард state='catched', спека соблюдена). НО торрент ДОБАВЛЕН в qBit под нашей категорией, будет качаться/сидировать вечно. Усыновить назад нельзя: adopt через ExistsByInfohash (любое состояние) → хеши cancelled-задачи существуют. Торрент ест диск без видимой задачи и владельца. Спека покрывает переход состояния, но не побочный эффект. Инвариант «источник неприкосновенен» — но этот торрент добавили МЫ после cancel-намерения.
Фикс-опции: (a) re-read state прямо перед Add (сужает окно); (b) при promote-skip из-за cancel — WARN «torrent left in qBittorrent»; (c) scoped delete/pause только что добавленного нами.
Вердикт: change (нужно решение по инварианту «источник неприкосновенен»).
@@ -1,11 +0,0 @@
# Defer из catched → лимбо → необратимый deleted (MAJOR-6)
**Приоритет:** средний · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). review.go:465-479, worker.go:332-340, reconcile.go:251-265.
Сценарий: задача в catched (ещё не добавлена в qBit) → user Defer → deferred. processCatched больше её не видит (листит только catched) → торрент никогда не добавится. Из deferred: Apply→«нет плана», Rerecognize/Refine→ensureSourcePresent нет торрента→reconcileToReality(sourcePresent=false, targetPresent=false)→deriveState=deleted, а у deleted НОЛЬ исходящих рёбер (download.go:102) → задача необратима, хотя байты .torrent лежат в download_torrent. Также deleted семантически неверен (ничего не качалось/раскладывалось).
Фикс: исключить catched из Defer (пре-источниковое состояние, «позже» бессмысленно) ИЛИ processCatched резюмит deferred-без-recognition ИЛИ preflight «источника не было никогда» (source_added_at IS NULL и нет links) → failed/qbit_add (retriable), не deleted.
Вердикт: простой фикс (исключить catched из Defer).
@@ -1,50 +0,0 @@
# Режиссёр в блоке «Распознано как» на странице загрузки
**Приоритет:** средний
На странице `/download/{id}` в блоке «Распознано как» режиссёр всегда показан
прочерком — поля просто нет:
`web/templates/partials/download_main.html:40` содержит захардкоженное
`<dt>Режиссёр</dt><dd><span class="faint">—</span></dd>`.
Экран ревью режиссёра уже выводит (`internal/httpapi/review.go:49/122`,
`review_source_block.html:59`) — это сделано в change про слоистое разрешение
полей display_name; страницу загрузки он не тронул.
Данные для вывода есть: `Plan.Director` (`internal/recognize/recognize.go:91`)
персистится в JSON-колонке `recognition.plan`, а страница загрузки уже получает
эффективный план через `Reviewer.ReviewData(...)`
(`internal/httpapi/download.go:89`) — `rd.Plan` уже с наложенными override
(`internal/worker/review.go:987`, `:1271`).
## Развилка (решить до кода)
`rd.Plan.Director` — это слой «override → распознавание+матч», **без слоя
контекста**. Полное слоистое разрешение живёт в неэкспортируемом
`effectiveDisplayName(d, plan)` (`internal/worker/review.go:1118`, fallback
`plan.Director → ctxf.Director` на `:1127-1130`) и отдаёт готовую строку, а не
поля.
- **A (минимум, ~3 строки):** поле `Director` в `downloadDetailView`
(`internal/httpapi/download.go:37`) + присвоение `view.Director =
rd.Plan.Director` рядом с `:143`. Но при распознавании без матча режиссёр из
контекста не покажется, хотя в заголовке страницы (display name) он уже есть →
видимая нестыковка «в заголовке есть, в поле прочерк».
- **B (правильный):** вынести слоистое разрешение в экспортируемое
`naming.Fields`/`EffectiveFields(d, plan)`, переиспользовать в
`effectiveDisplayName` и в **обоих** билдерах вью. Чинит заодно ту же дыру в
`internal/httpapi/review.go:122`.
Рекомендация — B: A оставляет ровно тот баг, который заводили.
## Спеки
- `openspec/specs/web-ui/spec.md` — про блок «Распознано как» на `/download/{id}`
режиссёра нет вообще → нужна дельта.
- `openspec/specs/review/spec.md:255-289` — сценарий «Режиссёр показан, когда
доступен» ограничен экраном ревью и формулировкой «из подтверждённого
матча/кандидата»; вариант B потребует правки формулировки.
- `openspec/specs/recognition/spec.md:62-69` — режиссёр опционален и best-effort,
пустое значение штатно.
Связано: [[ubiquitous-language-slovar]].
+12 -7
View File
@@ -181,10 +181,15 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
## Пересканирование Jellyfin ## Пересканирование Jellyfin
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе. пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
дёргается. ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
relink/лечения. Включается конфигом `[jellyfin]` (по умолчанию выключено); без
него скан не дёргается.
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан - **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
@@ -260,9 +265,9 @@ Dockerfile .dockerignore config.example.toml
задач (повторная закачка спустя время → новая задача). задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`. - Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas). - Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin после раскладки`POST /Library/Refresh` (скан - Пересканирование Jellyfin при изменении наших ссылок`POST /Library/Refresh`
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц., (скан всех библиотек, инкрементальный), неблокирующе на входе в `done`/
включается `[jellyfin]`. `reverted`/`deleted`; опц., включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review. - Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
+3 -1
View File
@@ -100,7 +100,9 @@ stateDiagram-v2
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin - **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование (опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и
**Удалить**`deleted` (полное удаление, см. ниже). **Удалить**`deleted` (полное удаление, см. ниже). Скан дёргается и при
входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
держать битые пути.
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка - **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить». (ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после - **reverted / cancelled → recognizing** — «Привязать заново»: после
+6
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
@@ -38,6 +39,7 @@ type downloadDetailView struct {
OriginalTitle string OriginalTitle string
Season string // сводка сезонов для сериала (пусто для фильма) Season string // сводка сезонов для сериала (пусто для фильма)
Year int Year int
Director string // режиссёр эффективного источника (пусто — неизвестен)
Provider string Provider string
ProviderID string ProviderID string
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом) MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
@@ -145,6 +147,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.Season = recognize.SeasonSummary(rd.Plan) view.Season = recognize.SeasonSummary(rd.Plan)
} }
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
// Режиссёр — из того же слоистого разрешения, что и display_name в шапке
// (override → распознавание+матч → контекст), чтобы поле не расходилось с
// заголовком; пустой во всех слоях → прочерк в шаблоне.
view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director
// Ручное обновление имени доступно, когда есть распознанное название, // Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op). // которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != "" view.Nameable = rd.Plan.Title != ""
+50
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -119,6 +120,55 @@ func TestRouterRendersPages(t *testing.T) {
} }
} }
// TestDownloadPageShowsDirector — блок «Распознано как» на /download/{id}
// выводит режиссёра слоистым разрешением: из плана (матч) и из сохранённого
// контекста при распознавании без матча (ранее поле было захардкожено прочерком).
func TestDownloadPageShowsDirector(t *testing.T) {
base := func(plan recognize.Plan, parsedContext string) *worker.ReviewData {
return &worker.ReviewData{
Download: store.Download{
ID: testULID, SourceRef: "Dune", State: store.StateReview,
ParsedContext: parsedContext,
},
Recognition: &store.Recognition{ID: "1", DownloadID: testULID, IsCurrent: true},
Plan: plan,
}
}
moviePlan := func(director string) recognize.Plan {
return recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024, Director: director,
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}}}
}
cases := []struct {
name string
rd *worker.ReviewData
wantInBody string
}{
{
name: "режиссёр из плана (матч)",
rd: base(moviePlan("Дени Вильнёв"), ""),
wantInBody: "Дени Вильнёв",
},
{
name: "режиссёр из контекста при распознавании без матча",
rd: base(moviePlan(""), `{"type":"movie","title":"Дюна","director":"Дени Вильнёв"}`),
wantInBody: "Дени Вильнёв",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := testRouter(t, stubReader{one: &c.rd.Download}, stubReviewer{data: c.rd})
rr := get(t, h, "/download/"+testULID)
if rr.Code != http.StatusOK {
t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), c.wantInBody) {
t.Errorf("страница загрузки не содержит режиссёра %q", c.wantInBody)
}
})
}
}
// TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком. // TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком.
func TestStaticServed(t *testing.T) { func TestStaticServed(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{}) h := testRouter(t, stubReader{}, stubReviewer{})
+5 -1
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
@@ -119,7 +120,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
view.IsSeries = rd.Plan.Type == "series" view.IsSeries = rd.Plan.Type == "series"
view.Title = rd.Plan.Title view.Title = rd.Plan.Title
view.OriginalTitle = rd.Plan.OriginalTitle view.OriginalTitle = rd.Plan.OriginalTitle
view.Director = rd.Plan.Director // Режиссёр — из слоистого разрешения (override → распознавание+матч →
// контекст), а не только из плана: иначе слой контекста (режиссёр без
// матча) терялся бы, как на странице загрузки.
view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
if view.IsSeries { if view.IsSeries {
view.SeasonSummary = recognize.SeasonSummary(rd.Plan) view.SeasonSummary = recognize.SeasonSummary(rd.Plan)
+112
View File
@@ -0,0 +1,112 @@
package naming
import (
"encoding/json"
"testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
func mustJSON(t *testing.T, f Fields) string {
t.Helper()
b, err := json.Marshal(f)
if err != nil {
t.Fatalf("marshal fields: %v", err)
}
return string(b)
}
func episode(season int) recognize.PlanFile {
s := season
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: &s}
}
// EffectiveFields: слоистое разрешение полей ярлыка (план → контекст) и сводка
// сезонов из плана/контекста. Проверяем и итоговый Label (совпадение с прежним
// display_name), и отдельно очищенное поле Director (его печатает шаблон).
func TestEffectiveFields(t *testing.T) {
cases := []struct {
name string
parsed Fields // пустой Title → parsed_context не задаётся
plan recognize.Plan
want string // ожидаемый Label()
wantDir string // ожидаемый Director
}{
{
name: "матч даёт режиссёра и год",
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна (Дени Вильнёв, 2024)",
wantDir: "Дени Вильнёв",
},
{
name: "режиссёр из контекста переживает распознавание без матча",
parsed: Fields{Type: "movie", Title: "Дюна", Director: "Дени Вильнёв", Year: 2021},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}, // без director
want: "Дюна (Дени Вильнёв, 2024)", // год из плана, режиссёр из контекста
wantDir: "Дени Вильнёв",
},
{
name: "режиссёр матча бьёт контекстного",
parsed: Fields{Type: "movie", Title: "Дюна", Director: "Кто-то из контекста"},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна (Дени Вильнёв, 2024)",
wantDir: "Дени Вильнёв",
},
{
name: "год из контекста, когда плана нет",
parsed: Fields{Type: "movie", Title: "Брат", Year: 1997},
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Брат"}, // year 0, без режиссёра
want: "Брат (1997)",
wantDir: "",
},
{
name: "сериал: сводка сезонов из плана",
plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Фарго",
Files: []recognize.PlanFile{episode(1), episode(2), episode(3)}},
want: "Фарго. Сезоны 13",
wantDir: "",
},
{
name: "сериал: сезон из контекста, когда в плане нет эпизодов",
parsed: Fields{Type: "series", Title: "Сёгун", Season: ptr(2)},
plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Сёгун", Year: 2024},
want: "Сёгун (2024). Сезон 2",
wantDir: "",
},
{
name: "режиссёр из управляющих символов и лишних пробелов очищается",
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: " Дени\tВильнёв\n ", Year: 2024},
want: "Дюна (Дени Вильнёв, 2024)",
wantDir: "Дени Вильнёв",
},
{
name: "режиссёр только из управляющих символов даёт пустое поле и выпадает из ярлыка",
plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "\x01\x02", Year: 2024},
want: "Дюна (2024)",
wantDir: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
parsed := ""
if c.parsed.Title != "" {
parsed = mustJSON(t, c.parsed)
}
ef := EffectiveFields(parsed, c.plan)
if got := ef.Label(); got != c.want {
t.Errorf("Label() = %q, want %q", got, c.want)
}
if ef.Director != c.wantDir {
t.Errorf("Director = %q, want %q", ef.Director, c.wantDir)
}
})
}
}
// Битый parsed_context не валит разрешение (best-effort): используется только план.
func TestEffectiveFieldsBrokenParsedContext(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}
if got := EffectiveFields("{не json", plan).Label(); got != "Дюна (2024)" {
t.Errorf("Label() = %q, want «Дюна (2024)»", got)
}
}
+57
View File
@@ -16,12 +16,14 @@ package naming
import ( import (
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/recognize"
) )
// maxNameLen — ограничение длины отображаемого имени (символов/рун). // maxNameLen — ограничение длины отображаемого имени (символов/рун).
@@ -45,6 +47,61 @@ type Fields struct {
IsRussian bool `json:"is_russian"` IsRussian bool `json:"is_russian"`
} }
// LabelFields — эффективные скалярные поля отображаемого ярлыка после слоистого
// разрешения (план → контекст). Значения уже очищены (sanitize): управляющие
// символы вырезаны, пробелы схлопнуты — те же, что попадут внутрь Label, поэтому
// присутствие/отсутствие поля определяется одинаково при прямом выводе (шаблон)
// и внутри Label. Season — готовая строка-сводка сезонов (для фильма пусто).
type LabelFields struct {
Title string
Director string
Year int
Season string
}
// Label собирает полный ярлык display_name из эффективных полей. sanitize внутри
// идемпотентен, поэтому на уже очищенных полях результат тот же.
func (f LabelFields) Label() string {
return Label(f.Title, f.Director, f.Year, f.Season)
}
// EffectiveFields разрешает поля отображаемого ярлыка слоями: план (override →
// распознавание+матч, уже свёрнут вызывающим) с фолбэком на извлечённый из
// контекста слой (parsedContext, схема Fields) для полей, которых план не дал
// (например режиссёр из контекста при распознавании без матча). Сводка сезонов —
// recognize.SeasonSummary(plan) с фолбэком на контекстный скаляр. parsedContext
// недоверен: битый JSON → пустой слой (best-effort). Выбор слоя идёт по сырым
// значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize),
// чтобы прямой вывод поля во вью совпадал с тем, что даёт Label в шапке.
func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields {
var ctxf Fields
if s := strings.TrimSpace(parsedContext); s != "" {
_ = json.Unmarshal([]byte(s), &ctxf)
}
title := plan.Title
if title == "" {
title = ctxf.Title
}
director := plan.Director
if director == "" {
director = ctxf.Director
}
year := plan.Year
if year == 0 {
year = ctxf.Year
}
season := recognize.SeasonSummary(plan)
if season == "" && plan.Type == recognize.MediaSeries {
season = ctxf.SeasonLabel()
}
return LabelFields{
Title: sanitize(title),
Director: sanitize(director),
Year: year,
Season: sanitize(season),
}
}
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с // SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от // заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary). // плана распознавания, где он per-file и сводится recognize.SeasonSummary).
+5 -4
View File
@@ -78,9 +78,10 @@ func (s State) IsTerminal() bool {
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в // (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive». // setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется. // Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer // - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной // пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё
// аккуратностью. // нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо —
// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью.
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо // - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный // Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только // и из терминальных (done/failed/reverted/target_missing/orphaned) — только
@@ -90,7 +91,7 @@ func (s State) IsTerminal() bool {
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе // Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
// setState отклонит переход (0 строк UPDATE → ошибка). // setState отклонит переход (0 строк UPDATE → ошибка).
var allowedTransitions = map[State][]State{ var allowedTransitions = map[State][]State{
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred}, StateCatched: {StateDownloading, StateFailed, StateCancelled},
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred}, StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred}, StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred}, StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
+14 -7
View File
@@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) {
} }
} }
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального // Инвариант Defer: deferred — легальная цель из каждого не-терминального
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли // состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в
// состояние» (напр. linking после краха процесса). // qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это
func TestDeferReachableFromEveryNonTerminal(t *testing.T) { // самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха
// процесса).
func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) {
for _, s := range allStates { for _, s := range allStates {
if s.IsTerminal() || s == StateDeferred { if s.IsTerminal() || s == StateDeferred || s == StateCatched {
continue // deferred → deferred покрыт самопереходом continue // deferred → deferred покрыт самопереходом; catched исключён
} }
if !slices.Contains(transitionSources[StateDeferred], s) { if !slices.Contains(transitionSources[StateDeferred], s) {
t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное)", s) t.Errorf("%s → deferred не легально (Defer допускает любое не-терминальное, кроме catched)", s)
} }
} }
// Пре-источниковое catched → deferred не легально: ребро снято из графа
// заодно с гардом в Worker.Defer.
if slices.Contains(transitionSources[StateDeferred], StateCatched) {
t.Errorf("catched → deferred легально, но должно быть снято (Defer отклоняет catched)")
}
} }
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из // Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
+118 -6
View File
@@ -214,9 +214,10 @@ func TestProcessCatchedTimeoutFails(t *testing.T) {
} }
} }
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили, // F3: отмена во время (медленного) вывода имени видна re-read'ом состояния ПЕРЕД
// переход в downloading не применяется — состояние остаётся cancelled. // Add — источник в qBittorrent не добавляется вовсе (раньше Add успевал пройти,
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) { // оставляя неуправляемый торрент без владельца).
func TestProcessCatchedCancelledDuringNamerSkipsAdd(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx") st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} qb := &fakeQbt{}
w := newTestWorker(st, qb) w := newTestWorker(st, qb)
@@ -226,11 +227,122 @@ func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
w.processCatched(context.Background()) w.processCatched(context.Background())
if len(qb.added) != 1 { if len(qb.added) != 0 {
t.Fatal("add должен был вызваться (сеть идёт вне замка)") t.Errorf("Add не должен вызываться после отмены (re-read перед add), calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("торрент не добавляли — удалять нечего, Delete calls = %d", len(qb.deleted))
} }
if st.downloads["1"].State != store.StateCancelled { if st.downloads["1"].State != store.StateCancelled {
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State) t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
}
// F3, scoped cleanup: отмена приходит в окне ПОСЛЕ успешного Add, но до записи
// перехода (onAdd имитирует Cancel ровно между Add и PromoteCatched). Добавленный
// НАМИ торрент удаляется из qBittorrent С ДАННЫМИ (уборка своего артефакта).
func TestProcessCatchedCancelledAfterAddRemovesTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
qb.onAdd = func() { st.downloads["1"].State = store.StateCancelled }
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 1 {
t.Fatalf("добавленный нами торрент должен быть удалён, Delete calls = %d", len(qb.deleted))
}
if !qb.deletedData[0] {
t.Error("уборка своего артефакта должна идти С ДАННЫМИ (deleteFiles=true)")
}
if len(qb.deleted[0]) == 0 || qb.deleted[0][0] != catchedIH {
t.Errorf("удаление не по infohash загрузки: %v", qb.deleted[0])
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (уборка не трогает состояние)", st.downloads["1"].State)
}
}
// F3, негативный инвариант «удаляем только своё»: тот же infohash появился в
// qBittorrent во время namer (внешний клиент, окно гонки). Свежий листинг перед
// Add видит присутствие → Add не делаем И чужой торрент С ДАННЫМИ не удаляем.
func TestProcessCatchedPreexistingTorrentNotDeleted(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} // снимок тика пуст → идём обычным путём добавления
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "X", onCall: func() {
// внешний клиент добавил тот же торрент, пока выводилось имя
qb.torrents = []qbt.Torrent{{Hash: catchedIH, Name: "external"}}
}}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться: infohash уже присутствует перед add, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("пред-существующий (чужой) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (усыновление на следующем тике)", st.downloads["1"].State)
}
}
// F3, safety-critical: свежий листинг присутствия ПЕРЕД add не удался (сеть
// отвалилась между тик-снимком и проверкой). Отсутствие infohash не подтверждено
// → Add не делаем (иначе delete-с-данными стал бы небезопасен), остаёмся в
// catched. onTorrents роняет ВТОРОЙ вызов Torrents (первый — тик-снимок).
func TestProcessCatchedPresenceRecheckFailKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
calls := 0
qb.onTorrents = func() {
calls++
if calls == 2 { // тик-снимок (1) ок, свежий листинг перед add (2) падает
qb.torrentsErr = errors.New("connection refused")
}
}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться при неподтверждённом отсутствии, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("Delete не должен вызываться, calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор на следующем тике)", st.downloads["1"].State)
}
}
// F3, различение «отмена vs сбой БД»: PromoteCatched упал транзиентно, но задача
// ЖИВА (state остался catched). Наш торрент НЕ удаляем — переход доведётся на
// следующем тике усыновлением присутствующей раздачи.
func TestProcessCatchedPromoteDBErrorKeepsTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
st.promoteErr = errors.New("db is locked") // сбой записи перехода, state = catched
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("при транзиентном сбое БД (задача жива) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор промоушена на следующем тике)", st.downloads["1"].State)
} }
} }
+14 -30
View File
@@ -136,8 +136,9 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
return res, savePath, nil return res, savePath, nil
} }
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 // finishRecognition сохраняет попытку распознавания и двигает задачу: при
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review. // уверенном матче (Decision.Auto) и чистой валидации — авто-раскладка, иначе —
// в review (см. ветвление ниже).
func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) { func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) {
log := logctx.From(ctx) log := logctx.From(ctx)
planJSON, err := json.Marshal(res.Plan) planJSON, err := json.Marshal(res.Plan)
@@ -550,6 +551,12 @@ func (w *Worker) Defer(ctx context.Context, id string) (err error) {
if d.State.IsTerminal() { if d.State.IsTerminal() {
return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict) return fmt.Errorf("defer: download %s is terminal (%s): %w", id, d.State, ErrConflict)
} }
// Пре-источниковое catched (торрент ещё не добавлен в qBittorrent) откладывать
// нечего: задача не дошла до ревью, а catched → deferred увёл бы её в лимбо —
// processCatched листает только catched и больше её не подхватит (MAJOR-6).
if d.State == store.StateCatched {
return fmt.Errorf("defer: источник ещё не добавлен в qBittorrent (%s), отложить можно после добавления: %w", d.State, ErrConflict)
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "") w.transition(ctx, *d, store.StateDeferred, "", "")
return nil return nil
@@ -1135,35 +1142,12 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P
return applyOverrides(plan, overrides), prov, pid, nil return applyOverrides(plan, overrides), prov, pid, nil
} }
// effectiveDisplayName собирает полный ярлык display_name из эффективных полей: // effectiveDisplayName собирает полный ярлык display_name из эффективных полей.
// плана (override → распознавание+матч, уже свёрнуто effectivePlan) с fallback на // Тонкая обёртка над naming.EffectiveFields (слоистое разрешение план →
// сохранённый контекст (parsed_context) для полей, которых план не дал (например // контекст) + Label — единый источник разрешения полей ярлыка, общий со
// режиссёр из контекста при распознавании без матча). Сводка сезонов — // страницей загрузки и экраном ревью.
// recognize.SeasonSummary(plan) с fallback на контекстный скаляр. Единый формат с
// шагом добавления (общий naming.Label). parsed_context недоверен: битый JSON →
// пустой слой (best-effort), очистка полей — на рендере.
func effectiveDisplayName(d store.Download, plan recognize.Plan) string { func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
var ctxf naming.Fields return naming.EffectiveFields(d.ParsedContext, plan).Label()
if s := strings.TrimSpace(d.ParsedContext); s != "" {
_ = json.Unmarshal([]byte(s), &ctxf)
}
title := plan.Title
if title == "" {
title = ctxf.Title
}
director := plan.Director
if director == "" {
director = ctxf.Director
}
year := plan.Year
if year == 0 {
year = ctxf.Year
}
season := recognize.SeasonSummary(plan)
if season == "" && plan.Type == recognize.MediaSeries {
season = ctxf.SeasonLabel()
}
return naming.Label(title, director, year, season)
} }
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени // RefreshDisplayName — внешняя точка входа обновления отображаемого имени
+83
View File
@@ -100,6 +100,70 @@ func TestScanner_FiresOnDone(t *testing.T) {
} }
} }
// waitScan ждёт вызова пересканирования Jellyfin.
func waitScan(t *testing.T, s *recordingScanner, when string) {
t.Helper()
select {
case <-s.ch:
case <-time.After(2 * time.Second):
t.Fatalf("пересканирование Jellyfin %s не запустилось", when)
}
}
// TestScanner_FiresOnReverted — после Undo наши библиотечные хардлинки сняты, вход
// в reverted тоже дёргает пересканирование (Jellyfin не держит битые пути).
func TestScanner_FiresOnReverted(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Скан подключаем ПОСЛЕ раскладки, чтобы поймать именно вход в reverted.
s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s)
if err := f.w.Undo(context.Background(), "1"); err != nil {
t.Fatalf("Undo: %v", err)
}
waitScan(t, s, "после Undo")
}
// TestScanner_FiresOnDeleted — после Delete наши ссылки сняты, вход в deleted
// дёргает пересканирование.
func TestScanner_FiresOnDeleted(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s)
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
waitScan(t, s, "после Delete")
}
// TestScanner_SilentOnNonLinkChange — вход, не меняющий наши библиотечные ссылки
// (deferred), скан не дёргает: гейт только по {done, reverted, deleted}.
func TestScanner_SilentOnNonLinkChange(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
s := &recordingScanner{ch: make(chan struct{}, 4)}
w.SetScanner(s)
if err := w.Defer(context.Background(), "1"); err != nil {
t.Fatalf("Defer: %v", err)
}
select {
case <-s.ch:
t.Fatal("скан не должен дёргаться на входе в deferred")
case <-time.After(200 * time.Millisecond):
}
}
func revertedDownload(id string) *store.Download { func revertedDownload(id string) *store.Download {
d := completedDownload(id) d := completedDownload(id)
d.State = store.StateReverted d.State = store.StateReverted
@@ -944,6 +1008,25 @@ func TestDefer(t *testing.T) {
} }
} }
// Defer из пре-источникового catched отклоняется конфликтом: торрент ещё не
// добавлен в qBittorrent, откладывать нечего, а catched → deferred увёл бы
// задачу в лимбо → необратимый deleted (MAJOR-6). Состояние не меняется.
func TestDeferRejectsCatched(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateCatched
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
err := w.Defer(context.Background(), "1")
if !errors.Is(err, ErrConflict) {
t.Fatalf("Defer from catched = %v, want ErrConflict", err)
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (не тронуто)", st.downloads["1"].State)
}
}
// applyFixture — реальный layouter с temp-библиотеками и исходными файлами. // applyFixture — реальный layouter с temp-библиотеками и исходными файлами.
type applyFixture struct { type applyFixture struct {
w *Worker w *Worker
+85 -9
View File
@@ -476,6 +476,38 @@ func (w *Worker) processCatched(ctx context.Context) {
} }
} }
addReq.Rename = rename addReq.Rename = rename
// F3: re-read состояния прямо перед Add — вывод имени (LLM) шёл секунды вне
// замка, задачу могли отменить (catched → cancelled). Если уже не catched,
// источник в qBittorrent не добавляем вовсе (иначе остался бы неуправляемый
// торрент без задачи-владельца).
w.mu.Lock()
before, berr := w.store.GetDownload(cctx, d.ID)
stillCatched := berr == nil && before != nil && before.State == store.StateCatched
w.mu.Unlock()
if !stillCatched {
logctx.From(cctx).Info("catched add skipped before qbittorrent", "reason", "no longer catched")
continue
}
// F3, гарантия «удаляем только своё»: свежим листингом (вне замка)
// подтверждаем, что раздачи с нашим infohash в qBittorrent ЕЩЁ НЕТ. Только
// тогда торрент, появившийся под этим хешем сразу после нашего Add, — наш
// артефакт, и позднейшая уборка вправе снести его С ДАННЫМИ. Сбой листинга →
// отсутствие не подтверждено, Add не делаем (повтор на следующем тике),
// иначе delete-с-данными стал бы небезопасен. Присутствие → внешний клиент
// добавил тот же торрент в окно гонки: Add не делаем, усыновит следующий тик
// (promoteExisting); чужие данные не трогаем.
snap, ferr := w.qbt.Torrents(cctx, "")
if ferr != nil {
logctx.From(cctx).Warn("catched presence recheck failed, will retry", "error", ferr)
continue
}
if _, present := torrentFor(*before, torrentsByHash(snap)); present {
logctx.From(cctx).Info("catched torrent already present in qbittorrent, will adopt")
continue
}
addErr := w.qbt.Add(cctx, addReq) addErr := w.qbt.Add(cctx, addReq)
if addErr != nil { if addErr != nil {
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched, // Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
@@ -484,16 +516,42 @@ func (w *Worker) processCatched(ctx context.Context) {
continue continue
} }
// Успех: короткий переход под w.mu с ре-валидацией state=catched // Успех Add: короткий переход под w.mu с ре-валидацией state=catched
// (загрузку могли отменить, пока шли сетевые вызовы). // (загрузку могли отменить, пока шли сетевые вызовы).
w.mu.Lock() w.mu.Lock()
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil { perr := w.store.PromoteCatched(cctx, d.ID, rename)
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error()) if perr == nil {
} else {
logctx.From(cctx).Info("state transition", "from", store.StateCatched, logctx.From(cctx).Info("state transition", "from", store.StateCatched,
"to", store.StateDownloading) "to", store.StateDownloading)
}
w.mu.Unlock() w.mu.Unlock()
continue
}
// Промоут не прошёл. Причину определяем СВЕЖИМ состоянием под тем же замком,
// а НЕ текстом ошибки (см. errors.md): PromoteCatched возвращает ошибку и при
// отмене (state != catched), и при транзиентном сбое БД (state всё ещё
// catched, задача жива).
after, aerr := w.store.GetDownload(cctx, d.ID)
cancelled := aerr == nil && after != nil && after.State != store.StateCatched
w.mu.Unlock()
if !cancelled {
// Транзиентный сбой БД (или не смогли перечитать) — торрент наш и живой,
// не удаляем: переход доведётся на следующем тике усыновлением
// присутствующей раздачи (promoteExisting).
logctx.From(cctx).Warn("catched promote failed, will retry", "error", perr)
continue
}
// F3: отмена (catched → cancelled) в окне между Add и записью перехода.
// Торрент добавлен НАМИ этим Add (отсутствие infohash подтверждено выше), а
// задачи-владельца больше нет — снимаем свой артефакт С ДАННЫМИ. Инвариант
// «источник неприкосновенен» защищает пользовательские данные, а не наш
// только что добавленный торрент; удаление идёт через API qBittorrent, не
// прямыми fs-операциями.
logctx.From(cctx).Warn("torrent left in qbittorrent after cancel, removing", "error", perr)
if delErr := w.qbt.Delete(cctx, before.HashList(), true); delErr != nil {
logctx.From(cctx).Error("cleanup added torrent after cancel failed", "error", delErr)
} else {
logctx.From(cctx).Warn("added torrent removed after cancel")
}
} }
} }
@@ -836,10 +894,14 @@ func (w *Worker) transitionErr(ctx context.Context, d store.Download, state stor
} }
} }
// Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы // Наши библиотечные хардлинки изменились — просим Jellyfin пересканировать
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне // библиотеку, чтобы плеер не держал битые пути и быстрее подхватил новые
// w.mu; недоступность Jellyfin не влияет на состояние задачи. // файлы. Триггерят входы, где раскладка «улеглась»: done (ссылки разложены),
if w.scanner != nil && state == store.StateDone { // reverted (Undo снял ссылки), deleted (Delete снял / сверка констатировала
// отсутствие). target_missing/orphaned — промежуточный рассинхрон, ждём
// relink/лечения, не сканируем. Неблокирующе и вне w.mu; недоступность
// Jellyfin не влияет на состояние задачи.
if w.scanner != nil && triggersScan(state) {
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером // Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin // (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим. // на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
@@ -849,6 +911,20 @@ func (w *Worker) transitionErr(ctx context.Context, d store.Download, state stor
return nil return nil
} }
// triggersScan сообщает, стоит ли на входе в state дёргать пересканирование
// Jellyfin: наши библиотечные хардлинки только что изменились. Гейт по
// состоянию-цели в едином чекпоинте ловит и пользовательские Undo/Delete, и
// reconcile-производный deleted (инициатор роли не играет); target_missing/
// orphaned — промежуточный рассинхрон (ждём relink/лечения) — исключены.
func triggersScan(state store.State) bool {
switch state {
case store.StateDone, store.StateReverted, store.StateDeleted:
return true
default:
return false
}
}
// shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи // shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается // (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы // под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
+11 -1
View File
@@ -24,6 +24,7 @@ type fakeStore struct {
downloads map[string]*store.Download downloads map[string]*store.Download
transitions []transition transitions []transition
torrents map[string][]byte // download_id → байты .torrent torrents map[string][]byte // download_id → байты .torrent
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
} }
type transition struct { type transition struct {
@@ -182,6 +183,9 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
if !ok { if !ok {
return fmt.Errorf("download %s not found", id) return fmt.Errorf("download %s not found", id)
} }
if f.promoteErr != nil {
return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
}
if d.State != store.StateCatched { if d.State != store.StateCatched {
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State) return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
} }
@@ -281,8 +285,10 @@ type fakeQbt struct {
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read) onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest added []qbt.AddRequest
addErr error addErr error
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
files []qbt.File files []qbt.File
deleted [][]string // хеши каждого вызова Delete deleted [][]string // хеши каждого вызова Delete
deletedData []bool // deleteFiles каждого вызова Delete (параллельно deleted)
deleteErr error deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name) renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error renameErr error
@@ -317,6 +323,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
} }
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.onAdd != nil {
f.onAdd()
}
if f.addErr != nil { if f.addErr != nil {
return f.addErr return f.addErr
} }
@@ -328,11 +337,12 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
return f.files, nil return f.files, nil
} }
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error { func (f *fakeQbt) Delete(_ context.Context, hashes []string, deleteFiles bool) error {
if f.deleteErr != nil { if f.deleteErr != nil {
return f.deleteErr return f.deleteErr
} }
f.deleted = append(f.deleted, hashes) f.deleted = append(f.deleted, hashes)
f.deletedData = append(f.deletedData, deleteFiles)
return nil return nil
} }
@@ -0,0 +1,57 @@
## Why
Пересканирование Jellyfin сейчас дёргается **только** при входе в `done` (после
успешной раскладки). Но наши библиотечные хардлинки меняются ещё в двух случаях:
**Undo** (`done → reverted`) и **Delete** (`… → deleted`) снимают ссылки. После них
Jellyfin продолжает показывать записи с битыми путями до следующего скана по
расписанию — рассинхрон видимого каталога с реальностью, который мы уже умеем
чинить, но не сигналим.
Интеграция готова целиком (`internal/jellyfin`, конфиг `[jellyfin]`, проводка
`SetScanner`) — не хватает лишь расширить условие срабатывания. Точка правки —
единый чекпоинт `transitionErr` (`internal/worker/worker.go`), через который уже
проходят оба пути снятия ссылок (`Undo`, `Delete`) и reconcile-производный
`deleted`.
## What Changes
- Расширить гейт пересканирования Jellyfin в `transitionErr` с
`state == done` на множество состояний **входа**, где наши библиотечные
хардлинки только что изменились: `done` (файлы разложены), `reverted` (Undo снял
ссылки), `deleted` (Delete снял ссылки / сверка констатировала их отсутствие).
- Гейт **по состоянию-цели** в едином чекпоинте: он естественно ловит и
пользовательские Undo/Delete, и reconcile-производный `deleted` — это
задумано и идемпотентно (лишний скан безвреден, инкрементальный скан дёшев).
- `target_missing``orphaned`) в множество **не** включаем: это промежуточные
состояния рассинхрона, где раскладка ещё не «улеглась» — источник жив, задача
ждёт relink/восстановления и может залечиться обратно в `done`. Скан там
откладываем, чтобы не слать его на каждое колебание сверки; когда задача
придёт в `done`/`deleted`, скан сработает по общему правилу.
- Зафиксировать поведение в спеке `file-layout` (сейчас про Jellyfin-скан в
`openspec/specs/` нет ни слова) и поправить рукописные доки, где формулировка
«при входе в `done`» стала неверной.
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `file-layout`: фиксируется триггер пересканирования Jellyfin — не только после
раскладки (`done`), но и после снятия наших библиотечных хардлинков
(`reverted`, `deleted`), неблокирующе и опционально (`[jellyfin]`).
## Impact
- Код воркера: `internal/worker/worker.go` — расширить условие скана в
`transitionErr` (`state == done` → множество `{done, reverted, deleted}`),
обновить поясняющий комментарий.
- Тесты: `internal/worker/review_test.go` — позитивные тесты, что скан стреляет
на `reverted` (после `Undo`) и `deleted` (после `Delete`); при желании
негативный (скан не стреляет на входе, не меняющем наши ссылки).
- Доки: `docs/specs/architecture.md` («Пересканирование Jellyfin»),
`docs/specs/workflow.md` — формулировку «при входе в `done`» заменить на
«после раскладки и после снятия наших ссылок (Undo/Delete)».
- Данные/инварианты: не затрагиваются. Скан по-прежнему неблокирующий, вне
`w.mu`, в фоновом ctx; недоступность Jellyfin на состояние задачи не влияет.
Источник неприкосновенен — скан лишь читает библиотеку.
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Пересканирование Jellyfin после изменения библиотечных ссылок
При сконфигурированном пересканировании Jellyfin (секция `[jellyfin]` включена) система SHALL при входе задачи в одно из состояний множества `{done, reverted, deleted}` **неблокирующе** просить Jellyfin пересканировать медиатеку (`POST /Library/Refresh`, скан всех библиотек). Эти три состояния — точки, где раскладка задачи **улеглась** так, что видимый Jellyfin каталог мог рассинхронизироваться с диском: `done` — наши хардлинки разложены (или восстановлены сверкой); `reverted` — Undo снял наши ссылки; `deleted` — ссылки сняты (Delete) либо констатировано их отсутствие (сверка), задача терминальна.
Условие срабатывания система SHALL проверять **по состоянию-цели перехода** в
едином чекпоинте записи состояния. Такой гейт SHALL естественно покрывать как
пользовательские команды (Undo → `reverted`, Delete → `deleted`), так и
reconcile-производный `deleted` — инициатор перехода роли не играет; повторный/
лишний скан безвреден (инкрементальный скан дёшев, операция идемпотентна).
Состояния **вне** этого множества система сканировать SHALL NOT. Сюда входят как
входы, не меняющие наши ссылки (`review`, `linking`, `cancelled` через Dismiss),
так и **промежуточные состояния рассинхрона** `target_missing` и `orphaned`: там
раскладка ещё не улеглась — задача ждёт relink/восстановления и может
«залечиться» обратно в `done`, поэтому скан на них система откладывает, а не шлёт
на каждое колебание сверки. `target_missing` система не сканирует сознательно,
хотя цель там пропала: это внешняя пропажа при живом источнике, не наше снятие.
Скан система SHALL выполнять **вне** блокировки воркера, в фоновом контексте и в
отдельной горутине, со scoped-логгером задачи для корреляции. Недоступность
Jellyfin на состояние задачи влиять SHALL NOT — ошибка вызова лишь логируется
(её пишет клиент Jellyfin как запись внешнего вызова). Если пересканирование не
сконфигурировано (`[jellyfin]` выключено), скан не дёргается ни в одном из этих
переходов.
#### Scenario: Скан после раскладки
- **GIVEN** пересканирование Jellyfin включено
- **WHEN** задача входит в `done` после успешной раскладки хардлинков
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Скан после отката (Undo)
- **GIVEN** пересканирование Jellyfin включено, задача в `done` с разложенными ссылками
- **WHEN** пользователь выполняет Undo и задача входит в `reverted` (наши ссылки сняты)
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Скан после удаления (Delete)
- **GIVEN** пересканирование Jellyfin включено, задача в `done`
- **WHEN** пользователь выполняет Delete и задача входит в `deleted` (наши ссылки сняты)
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Без конфигурации Jellyfin скан не дёргается
- **GIVEN** пересканирование Jellyfin выключено (`[jellyfin]` не сконфигурировано)
- **WHEN** задача входит в `done`, `reverted` или `deleted`
- **THEN** система скан не дёргает
#### Scenario: Вход вне множества не сканирует
- **GIVEN** пересканирование Jellyfin включено
- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
- **THEN** система скан не дёргает
@@ -0,0 +1,34 @@
## 1. worker — расширить гейт скана
- [x] 1.1 В `internal/worker/worker.go` (`transitionErr`) заменить условие
`state == store.StateDone` на проверку принадлежности `state` множеству
`{done, reverted, deleted}` (небольшой предикат-хелпер для читаемости).
Остальную механику скана (фоновый ctx, `capFileLayout`-scoped-логгер,
неблокирующая горутина, `w.scanner != nil`) переиспользовать как есть.
- [x] 1.2 Обновить поясняющий комментарий: скан не только «раскладка завершена»,
а «наши библиотечные хардлинки изменились» (разложены при `done`, сняты при
`reverted`/`deleted`).
## 2. Тесты
- [x] 2.1 `internal/worker/review_test.go`: тест, что скан стреляет на входе в
`reverted` (после `Undo`).
- [x] 2.2 Тест, что скан стреляет на входе в `deleted` (после `Delete`).
- [x] 2.3 (Опц.) Негативный точечный тест: на входе, не меняющем наши ссылки,
скан не дёргается.
## 3. Доки
- [x] 3.1 `docs/specs/architecture.md` («Пересканирование Jellyfin» и строка
«Решённые вопросы»): формулировку «при входе в `done`» заменить на «после
раскладки (`done`) и после снятия наших ссылок (`reverted`/`deleted`)».
- [x] 3.2 `docs/specs/workflow.md`: в описании `done` и снятия ссылок отразить,
что скан дёргается и после Undo/Delete.
## 4. Ревью и сверка
- [x] 4.1 `task test` и `task lint` зелёные.
- [x] 4.2 Ревью кода (второй чекпоинт) перед archive.
- [x] 4.3 `openspec validate 2026-07-17-jellyfin-scan-on-revert-delete --strict` зелёный.
- [x] 4.4 Синк дельты в `openspec/specs/file-layout`, архив change; удалить
`docs/backlog/jellyfin-skan-posle-udaleniya.md` и строку из индекса беклога.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,178 @@
## Context
`processCatched` (`internal/worker/worker.go`) добавляет пойманные загрузки в
qBittorrent. Медленные вызовы (namer/LLM, `qbt.Add`) идут **вне** блокировки
переходов `w.mu`, под замком — только короткие DB-переходы. Последовательность
на «обычном» пути добавления (торрента в снимке тика нет):
1. под `w.mu` re-read записи → проверка `state == catched`, актуализация
`source_type` (текущий код: worker.go:447453);
2. вне замка: `namer.Derive` (секунды) → `qbt.Add`;
3. под `w.mu`: `PromoteCatched` (гард `state='catched'`) → `catched → downloading`.
Гонка F3: отмена (`catched → cancelled`) приходит между шагом 1 и шагом 3. Гард
`PromoteCatched` честно отклоняет переход (`state` уже `cancelled`), но `qbt.Add`
на шаге 2 уже отработал — торрент добавлен под нашей категорией и остался в
qBittorrent без задачи-владельца. `adopt` его назад не подхватит:
`ExistsByInfohash` истинно (хеши принадлежат отменённой записи). Диск занят,
владельца нет.
## Goals / Non-Goals
**Goals**
- Не добавлять источник, если отмена видна ещё до `add`.
- Убрать добавленный нами торрент (с данными), если отмена случилась в окне после
`add`.
- Никогда не удалять с данными торрент, которого мы не создавали этим `add`.
**Non-Goals**
- Не меняем FSM-граф и семантику `PromoteCatched`/`cancel`.
- Не вводим новых состояний, полей БД, методов `qbt`/`store`.
- Не закрываем окно гонки полностью (у qBittorrent нет транзакции «add+own»);
сужаем его и гарантированно убираем последствия.
- **Не в scope: апгрейд `source_type` во время namer.** `source_type`/`addReq`
worker перечитывает перед namer (текущий код), а не в D1-re-read перед `add`.
Пред-существующий зазор «magnet→torrent апгрейд случился во время вывода имени»
этой задачей не закрывается и не ухудшается (D1 читает только состояние). D1
специально держим дешёвым (только `state`) и не тянем чтение байтов торрента под
`w.mu`; закрытие зазора — отдельная задача.
## Decisions
### D1. Re-read состояния прямо перед `qbt.Add`
После `namer.Derive` (медленный шаг) и **непосредственно перед** `qbt.Add`
worker берёт `w.mu`, перечитывает запись и проверяет `state == catched`. Если
уже не `catched` (отменена во время namer) — `add` не делаем, задачу пропускаем.
Это переносит основную защиту на самый частый сценарий: окно namer (секунды)
куда шире окна между `add` и записью перехода (один сетевой вызов).
Re-read под `w.mu` перечитывает только **состояние** (дёшево, локальный SQLite).
Актуализацию `source_type`/`addReq` он НЕ повторяет — см. «Границы блокировки» и
«Не в scope» ниже.
### D2. Проверка отсутствия infohash перед `add` — признак «своего» торрента
Сразу перед `add` (после D1, но **вне** `w.mu` — это сетевой вызов) worker
свежим листингом qBittorrent подтверждает, что раздачи с любым из infohash
загрузки **ещё нет**. Реализуется переиспользованием `qbt.Torrents(ctx, "")` (тот
же механизм, что снимок тика) — нового API не нужно.
- Если листинг **не удался** (сеть отвалилась) — worker `add` НЕ делает и задачу
в этот тик пропускает (повтор на следующем). Это критично: без подтверждённого
отсутствия «своё/чужое» неразличимо, и delete-with-data стал бы небезопасен.
Поведение то же, что уже принято для листинга тика (сбой → не трогаем).
- Если infohash **уже присутствует** (внешний клиент/пользователь добавил тот же
торрент в окно гонки после снимка тика) — worker `add` НЕ делает и задачу
пропускает: на следующем тике её штатно усыновит `promoteExisting` (ветка «уже
присутствует»). Чужие данные не трогаются.
- Если infohash **отсутствует** — только тогда делаем `add`. Тем самым любой
торрент, оказавшийся под этим infohash сразу после нашего `add`, — **наш**
артефакт.
Именно подтверждённое отсутствие-перед-`add` — механизм различения «своё/чужое».
Он не завязан на семантику ответа `qbt.Add` (qBittorrent на дубль отвечает тем же
`Ok.`, не сообщая, создал он раздачу или это был дубль).
### D3. Scoped cleanup при отмене в окне после `add`
Если `add` прошёл (D2 подтвердил отсутствие), а затем `PromoteCatched` не
применил переход, worker принимает решение об уборке **по свежему re-read
состояния под `w.mu`**, а не по тексту/факту ошибки `PromoteCatched`. Причина:
`PromoteCatched` возвращает ошибку в двух разных случаях — (1) гард `n==0`
(`state` действительно уже не `catched`, отмена) и (2) транзиентный сбой БД
(`state` всё ещё `catched`, задача жива). Вешать delete-with-data на «любую
ошибку промоушена» нельзя: при миге БД это снесло бы **свой же, но ещё активный**
торрент.
Поэтому под тем же `w.mu` worker перечитывает запись:
- `state != catched` (подтверждённая отмена) → удаляем добавленный торрент из
qBittorrent **с данными**: `qbt.Delete(ctx, hashes, deleteFiles=true)` по
infohash загрузки. `Delete` идемпотентен (неизвестный хеш qBittorrent
игнорирует). Сбой удаления — `ERROR`-лог, состояние задачи (`cancelled`) не
трогаем.
- `state == catched` (транзиентный сбой БД) или re-read сам упал → торрент НЕ
удаляем, `WARN` «promote failed, will retry»: на следующем тике
`promoteExisting` усыновит присутствующую (нашу же) раздачу — переход
доведётся, ничего не потеряно.
Cleanup достижим ТОЛЬКО по пути, где D2 подтвердил отсутствие infohash перед
`add`, — поэтому удаляемый торрент гарантированно создан этим `add`.
### Границы блокировки (сериализация переходов)
Порядок вокруг `add` соблюдает инвариант «медленные/сетевые вызовы вне `w.mu`»:
1. `[под w.mu]` re-read состояния (D1);
2. `[вне w.mu]` свежий листинг присутствия (D2) — сетевой вызов;
3. `[вне w.mu]` `qbt.Add`;
4. `[под w.mu]` `PromoteCatched` + (при неуспехе) re-read состояния для решения об
уборке (D3);
5. `[вне w.mu]` `qbt.Delete` при подтверждённой отмене.
Между шагами 1–3 остаётся окно (описанный TOCTOU), но `add` защищён свежим
подтверждением отсутствия (шаг 2), а любая пропажа гарантии деградирует к
безопасному «не удаляем» (D3).
### D4. Логи
- `WARN "torrent left in qbittorrent after cancel, removing"` — вход в cleanup,
поле-причина промаха `PromoteCatched`.
- `WARN "added torrent removed after cancel"` — факт успешного удаления.
- `ERROR` — если `qbt.Delete` не удался (мусор остался, нужен разбор).
- D1/D2-пропуски — `INFO` (штатная развилка, не проблема).
Все записи несут `download_id`/`infohash` (scoped-логгер `cctx`), без секретов.
Сам вызов `qbt.Delete`/`Torrents` логирует клиент (`ext.*`).
## Инвариант «источник неприкосновенен» и негативная гарантия
**Артикуляция решения.** Инвариант «источник неприкосновенен» (CLAUDE.md,
architecture.md, ADR-hardlinks) защищает **пользовательские данные** под
`paths.downloads`/существующие раздачи от НАШИХ прямых fs-операций
(`unlink`/`rename`). Торрент, который jellybit добавил секундами ранее — уже
после намерения отмены, — это **наш собственный артефакт**, а не пользовательские
данные. Его удаление через API qBittorrent (не прямыми fs-операциями) —
легитимная уборка своего мусора. Прецедент уже есть: команда «Удалить» (`delete`)
осознанно сносит раздачу с данными через `qbt.Delete(..., true)`
(state-reconciliation) — там обход инварианта санкционирован пользователем; здесь
удаляется лишь то, что мы сами только что создали вопреки уже выраженной отмене.
**Негативная гарантия (КРИТИЧНО).** Удаление-с-данными недопустимо для торрента,
который присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал
тот же infohash / внешний торрент с тем же хешем). Удалить его с данными означало
бы снести чужие данные — прямое нарушение инварианта. Защита — D2: удаление
достижимо только на пути, где отсутствие infohash подтверждено непосредственно
перед `add`; при обнаруженном присутствии `add` не делается вовсе, а торрент
уходит на усыновление. Так удаляется исключительно созданное нами этим `add`.
**Остаточное окно (честно).** Между проверкой присутствия (D2) и самим `add`
остаётся микроскопический TOCTOU-зазор: внешний клиент теоретически мог добавить
тот же infohash в этот промежуток (два последовательных сетевых вызова). У
qBittorrent нет атомарного «create-or-fail по infohash» и `add` не сообщает,
создал он раздачу или присоединился к дублю, — устранить зазор имеющимся API
нельзя. Мы сознательно выбираем `add` только при подтверждённом отсутствии
непосредственно перед ним (зазор на порядки меньше окна namer из D1) и считаем
это санкцией на уборку. Дальнейшее сужение (напр. сверка `added_on` раздачи со
временем нашего `add`) — возможное усиление на будущее, в этой задаче не делаем:
базис по времени хрупок (скос часов, грубое разрешение), а вероятность
внешнего добавления ровно в этот под-`add` зазор пренебрежимо мала.
## Risks / Trade-offs
- **Лишний листинг qBittorrent перед каждым `add`** пойманной загрузки. Добавления
событийны и редки (не поллинг), стоимость незначительна; переиспользуем
существующий `Torrents`.
- **Cleanup зависит от доступности qBittorrent.** Если `qbt.Delete` не прошёл —
торрент временно остаётся, но это уже отменённая задача; `ERROR`-лог фиксирует
для разбора. Повторной авто-уборки не вводим (не усложняем): случай редкий.
## Migration Plan
Изменение чисто поведенческое в `worker.processCatched`. Схема БД, конфиг, API
`qbt`/`store` не меняются. Откат — возврат прежней ветки добавления.
## Open Questions
Нет.
@@ -0,0 +1,56 @@
## Why
На шаге добавления пойманной загрузки (`catched`) worker выводит имя (LLM,
секунды) и вызывает `qbt.Add` **вне** блокировки переходов. Если в это окно
пользователь отменяет задачу (`catched → cancelled`), запись перехода
`PromoteCatched` корректно пропускается (гард `state='catched'`), НО источник
уже добавлен в qBittorrent под нашей категорией. Такой торрент качается/сидирует
вечно, ест диск, а видимой задачи-владельца нет: усыновить назад его нельзя —
`adopt` проверяет `ExistsByInfohash` (любое состояние), а хеши уже принадлежат
отменённой задаче. Спека покрывает переход состояния, но не этот побочный эффект
(находка ревью F3/NIT-13).
## What Changes
- **Re-read состояния прямо перед `qbt.Add`** (под блокировкой переходов, после
медленного вывода имени): если задача уже не в `catched` (отменена) — источник
в qBittorrent НЕ добавляется вовсе. Сужает окно гонки до промежутка между
re-read и записью перехода.
- **Scoped cleanup**: если отмена случилась в оставшемся окне (уже ПОСЛЕ
успешного `add`, но до записи перехода), worker удаляет только что добавленный
торрент из qBittorrent **вместе с данными** — уборка собственного мусора.
- **Гарантия «удаляем только своё»**: удаление-с-данными допустимо ТОЛЬКО для
торрента, который worker создал именно этим `add`. Признак — подтверждённое
**отсутствие** infohash в qBittorrent непосредственно перед `add`. Если
infohash уже присутствовал до нашего `add` (внешний клиент раздаёт тот же
торрент), worker источник не добавляет и чужие данные не трогает.
- **WARN-логи** на этом пути (торрент оставлен после отмены → удаляем; факт
удаления), с корреляцией по `download_id`, без секретов.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent» —
добавляется re-read состояния перед `add`, подтверждение отсутствия infohash
перед `add` как признак «своего» торрента и уборка добавленного торрента при
отмене в окне после `add`. Сценарий «Отмена во время добавления» уточняется и
дополняется сценариями уборки и негативного инварианта.
## Impact
- **Спеки:** дельта `download-tracking` (одно MODIFIED-требование + сценарии).
- **Код:** `internal/worker/worker.go``processCatched` (re-read state и
проверка присутствия перед `Add`; уборка добавленного при промахе
`PromoteCatched`). Новых методов `qbt`/`store` не требуется (переиспользуем
`Torrents`, `Delete`).
- **Тесты:** `internal/worker/catched_test.go` — обновление сценария отмены во
время namer (Add не вызывается) + новые: уборка после Add, негативный инвариант
(пред-существующий торрент не удаляется с данными).
- **Миграции БД:** нет.
- **Инвариант «источник неприкосновенен»:** обоснование удаления-с-данными и
негативная гарантия — в `design.md`.
@@ -0,0 +1,214 @@
## MODIFIED Requirements
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
(которое и означает «в qBit, возможно `metaDL`»).
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
загрузку, которую добавлять не требуется.
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
«усыновить и разложить», а не как конфликт с чужой задачей.
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
(см. «Предохранитель зависшего catched»).
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
(`source_type`):
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
загрузке при приёме) и передавать их **файлом** (`torrents` API
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
magnet-хешу вместо файла система SHALL NOT.
`source_type` для выбора способа добавления worker SHALL перечитывать **под
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
устаревшего снимка, хотя БД уже `torrent`.
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в `catched` для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — `catch_timeout`).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
блокировки]` `add``[под блокировкой]` запись перехода и (при неуспехе) re-read
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
пропустить. Это сужает окно гонки до промежутка между re-read и записью
перехода.
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
артефакт), а не пред-существовал.
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
worker SHALL принимать решение об уборке по **свежему re-read состояния под
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
**собственного** артефакта, а не пользовательских данных: инвариант «источник
неприкосновенен» защищает существующие раздачи/файлы пользователя под
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
(торрент временно остаётся, повторная авто-уборка не требуется).
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
отсутствие infohash было подтверждено непосредственно перед `add`; при
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
секретов; неуспех удаления — на `ERROR`.
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
сохранёнными байтами файла, торрента ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
нашей категорией и `rename`, без обращения к magnet-хешу
- **AND** загрузка переходит в `downloading`
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
прошлом тике, а запись перехода не удалась)
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
- **AND** `display_name` записывается из имени присутствующей раздачи
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Свежий листинг перед add недоступен — повтор
- **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
- **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
(сетевой сбой)
- **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
- **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
#### Scenario: Отмена до add — источник не добавляется
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
вывода имени, а затем worker перечитывает состояние перед `add`
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
вызывается
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
подтверждено перед `add`, и `add` прошёл успешно
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
перехода, из-за чего запись перехода не применяется, а re-read состояния под
блокировкой показывает `state != catched`
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
его данными (`deleteFiles = true`) по infohash загрузки
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
- **AND** состояние задачи остаётся `cancelled`
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
`catched` (отмены не было)
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
#### Scenario: Пред-существующий торрент не удаляется с данными
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
хешем)
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
неприкосновенны)
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)
@@ -0,0 +1,44 @@
## 1. Код
- [x] 1.1 В `processCatched` (`internal/worker/worker.go`), на обычном пути
добавления, ПОСЛЕ вывода имени и НЕПОСРЕДСТВЕННО перед `qbt.Add`: под `w.mu`
перечитать запись и, если `state != catched`, `add` не делать и загрузку
пропустить (D1)
- [x] 1.2 Там же (вне `w.mu`) добавить свежий листинг присутствия любого из
infohash загрузки в qBittorrent перед `add` (переиспользовать `qbt.Torrents`);
при присутствии — `add` не делать, пропустить (усыновит следующий тик); при
СБОЕ листинга — `add` не делать, `WARN`, повтор на следующем тике (D2, Б1)
- [x] 1.3 При неуспехе `PromoteCatched` после успешного `add` — под `w.mu`
перечитать состояние: только если `state != catched` (подтверждённая отмена) —
удалить добавленный торрент с данными `qbt.Delete(ctx, HashList, true)`; `WARN`
об оставленном/удалённом торренте; `ERROR` при сбое удаления. Если `state ==
catched` (транзиентный сбой БД) или re-read упал — НЕ удалять, `WARN`
«promote failed, will retry» (D3, Б2). Решение по состоянию, не по тексту
ошибки (см. errors.md)
- [x] 1.4 Убедиться, что путь уборки достижим только после подтверждённого
отсутствия перед `add` (негативный инвариант «удаляем только своё»)
## 2. Тесты
- [x] 2.1 Обновить `TestProcessCatchedCancelledDuringAddSkipsPromote`: отмена во
время namer → `qbt.Add` НЕ вызывается (re-read перед add), состояние остаётся
`cancelled` (было: Add вызывался)
- [x] 2.2 Новый тест: отмена в окне ПОСЛЕ `add` (hook на `Add`, ставящий
`cancelled`) → `qbt.Delete` вызван с `deleteFiles=true` по infohash загрузки,
состояние `cancelled`
- [x] 2.3 Новый тест (негативный инвариант): infohash появился в qBittorrent во
время namer (hook на namer) → перед `add` листинг видит присутствие → `qbt.Add`
НЕ вызывается, `qbt.Delete` НЕ вызывается (чужие данные не трогаем)
- [x] 2.4 Новый тест (Б2): сбой `PromoteCatched` при `state == catched`
(транзиентная ошибка БД, без отмены) → торрент НЕ удаляется (`qbt.Delete` не
вызван)
- [x] 2.6 Новый тест (Б1, safety-critical): свежий листинг перед `add` упал
(второй вызов `Torrents`) → `Add`/`Delete` НЕ вызваны, остаётся `catched`
- [x] 2.5 Регрессия: штатный успех (нет отмены) по-прежнему добавляет и
промоутит (`TestProcessCatchedAddsToQbit`); усыновление присутствующего и сбой
листинга тика (`TestProcessCatchedListErrorKeepsCatched`) — без изменений
## 3. Спека
- [x] 3.1 MODIFIED-требование «Добавление пойманной загрузки в qBittorrent» в
`download-tracking`; `openspec validate cancel-during-add-cleanup --strict`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,44 @@
## Context
Фикс простой (гард одной команды + снятие мёртвого ребра графа). Дизайн
фиксирует два решения, чтобы ревью дизайна прошло до кода.
## Решение 1: где гардить Defer
`Defer` (`review.go`) отличается от прочих команд ревью: он не использует
`requireReviewable` (review/deferred), а сознательно широк — паркует любую
не-терминальную задачу (downloading/completed/recognizing/review/linking/stuck/
deferred), потому что «отложить» осмысленно и для ещё качающейся задачи. Значит
фикс НЕ «сузить Defer до reviewable», а точечно исключить пре-источниковое
`catched`.
Гард добавляем в сам `Defer` после `GetDownload`, рядом с существующей проверкой
`IsTerminal()`: `if d.State == store.StateCatched { return ...ErrConflict }`.
`ErrConflict` уже маппится в `httpapi.classifyErr` → 409 «действие недоступно в
текущем состоянии» (см. docs/conventions/errors.md), новый sentinel не нужен.
Сообщение обёртки — операторская диагностика для логов; наружу транспорт отдаёт
нейтральный маппинг.
## Решение 2: граф переходов
`allowedTransitions[StateCatched]` содержит `StateDeferred`. После гарда это
ребро мёртвое (единственный переход в `deferred` — команда `Defer`). Убираем
`StateDeferred` из исходящих `catched`, чтобы граф оставался тесным
надмножеством реальных переходов (инвариант спеки «граф — источник истины о
легальности рёбер»). Обновляем комментарий инварианта `deferred` в download.go
(«из КАЖДОГО не-терминального» → «кроме пре-источникового catched») и тест
`transition_test.go`, который его закрепляет.
Проверка полноты пре-источниковых состояний: `catched` — единственное
не-терминальное состояние без раздачи в qBittorrent. От `downloading` и далее
раздача есть; приёмное падение до `downloading` — терминальный `failed`
(`qbit_add`), Defer его уже отклоняет через `IsTerminal()`. Значит достаточно
исключить `catched`.
## Границы scope
Не трогаем `processCatched`, `reconcile`, граф `deleted`: корень бага —
единственный вход в лимбо (`catched → deferred`), закрытие входа устраняет всю
цепочку. Альтернативы из файла задачи (резюме deferred в processCatched;
preflight «источника не было» → failed/qbit_add) не нужны — вердикт задачи
«простой фикс».
@@ -0,0 +1,64 @@
## Why
Команда **Defer** («Позже») сейчас гардит только `IsTerminal()`, поэтому
принимает и пре-источниковое состояние `catched` (торрент ещё НЕ добавлен в
qBittorrent). Defer из `catched` уводит задачу в лимбо → необратимый `deleted`
(находка ревью MAJOR-6):
- `catched → deferred`: `processCatched` листает только `catched` и задачу
больше не видит → торрент никогда не добавится в qBittorrent.
- Из `deferred` дальше тупик: `Apply` → «нет плана»; `Rerecognize`/`Refine`
`ensureSourceReady` не находит раздачу → сверка (`sourcePresent=false`,
`targetPresent=false`) выводит `deleted`, а у `deleted` НОЛЬ исходящих рёбер
→ задача необратима, хотя байты `.torrent` лежат в `download_torrent`.
- Плюс `deleted` семантически неверен: у `catched` ничего не качалось и не
раскладывалось.
Defer до появления источника бессмысленен: «отложить на потом» нечего — задача
ещё не дошла до ревью. Пре-источниковое `catched` — единственное такое
состояние (все состояния от `downloading` и далее уже имеют раздачу в
qBittorrent; приёмное падение `qbit_add` терминально и Defer его уже отклоняет).
## What Changes
- **Defer отклоняет пре-источниковое состояние `catched`** с конфликтом
(`ErrConflict`) и понятным сообщением: отложить можно только после добавления
торрента в qBittorrent. Прочие не-терминальные состояния (`downloading`/
`completed`/`recognizing`/`review`/`linking`/`stuck`/`deferred`), где раздача
уже есть, Defer принимает как и раньше.
- **Граф переходов теряет ребро `catched → deferred`** — раз Defer его больше
не выполняет, ребро мёртвое; граф остаётся тесным надмножеством реальных
переходов. Инвариант «`deferred` — легальная цель из каждого не-терминального
состояния» уточняется: **кроме** пре-источникового `catched`.
- UI/HTTP уже не предлагает Defer для `catched`: кнопка «🕗 Позже» живёт только
на экране ревью (`review`/`deferred`), карточка `catched` лишь самополлингом
ждёт перехода в `downloading`. Прямой вызов Defer для `catched` теперь
отклоняется доменным гардом; транспорт транслирует отказ по своему каналу:
REST — 409 «действие недоступно в текущем состоянии» (`classifyErr`), веб-путь
`/ui/downloads/{id}/defer` — PRG-редирект (303) на `/review/{id}?err=…` с
нейтральным сообщением (как прочие отказы команд ревью).
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `review`: требование «Команды ревью и их эффекты» — уточняет допустимые
исходные состояния команды **Позже** (`Defer`): любое не-терминальное, кроме
пре-источникового `catched` (там нет раздачи и нечего откладывать).
## Impact
- **Спеки:** дельта `review` (одно MODIFIED-требование с негативным сценарием).
Требование графа переходов в `download-tracking` (декларативное, конкретные
рёбра не перечисляет) не меняется — снятие ребра `catched → deferred` из
единого источника истины в коде ему не противоречит.
- **Код:** `internal/worker/review.go``Defer` (гард против `catched`);
`internal/store/download.go` — убрать `StateDeferred` из исходящих `catched`
и уточнить комментарий инварианта `deferred`.
- **Тесты:** `internal/worker/review_test.go` — Defer из `catched` отклоняется,
из `review` по-прежнему работает; `internal/store/transition_test.go`
инвариант «`deferred` из каждого не-терминального, кроме `catched`».
@@ -0,0 +1,95 @@
## MODIFIED Requirements
### Requirement: Команды ревью и их эффекты
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда.
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ
добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю
сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched`
единственное состояние без раздачи среди не-терминальных: откладывать в нём
нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в
лимбо — `processCatched` листает только `catched` и больше её не подхватит, а
последующие команды через отсутствие источника выводят необратимый `deleted`.
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
дебаунса) проверять перед действием, что источник не только присутствует в
qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния
(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5),
т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное
или переходное `moving`/`checking` состояние), команда SHALL отказывать с
конфликтом и причиной «торрент ещё качается», НЕ создавая хардлинки и НЕ меняя
состояние загрузки (её нахождение в `review`/`deferred`/… легитимно, приводить
к реальности нечего). Отсутствие источника в qBittorrent SHALL по-прежнему
приводить состояние к реальности (`orphaned`/`deleted`) и отказывать. Так
недокачанная задача не может пройти через перераспознавание в авто-раскладку
или ручное применение и захардлинкать неполные файлы, обойдя финальность
состояния `completed`.
#### Scenario: Применение создаёт раскладку
- **GIVEN** загрузка в `review` с эффективным планом
- **WHEN** пользователь выбирает «Применить»
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
#### Scenario: Отклонить и привязать заново
- **GIVEN** загрузка в `review`
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
#### Scenario: Тип не переключается кнопкой
- **GIVEN** загрузка в `review` с распознанным типом
- **WHEN** пользователь открывает экран ревью
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника
#### Scenario: Позже паркует задачу из ревью
- **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть)
- **WHEN** пользователь выбирает «Позже»
- **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по
любому последующему действию
#### Scenario: Позже отклоняется для пре-источникового catched
- **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent)
- **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на
`/ui/downloads/{id}/defer`)
- **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить
можно только после добавления торрента
- **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через
`processCatched`
#### Scenario: Недокачанный источник отклоняет перераспознавание
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
качается (`downloading`, файлы не докачаны)
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
заново»/фиксацию типа)
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
не запускается
#### Scenario: Недокачанный источник отклоняет ручное применение
- **GIVEN** загрузка в `review`, чья раздача в qBittorrent ещё качается
- **WHEN** пользователь выбирает «Применить»
- **THEN** команда отклоняется с конфликтом «торрент ещё качается», хардлинки
на неполные файлы не создаются, состояние загрузки не меняется
@@ -0,0 +1,30 @@
## 1. Код
- [x] 1.1 `internal/worker/review.go` — в `Defer` после `GetDownload` добавить
гард: `d.State == store.StateCatched``ErrConflict` с понятным сообщением
(отложить можно только после добавления торрента в qBittorrent), не меняя
состояние. Существующий гард `IsTerminal()` оставить.
- [x] 1.2 `internal/store/download.go` — убрать `StateDeferred` из исходящих
`StateCatched` в `allowedTransitions`; уточнить комментарий инварианта
`deferred` («из КАЖДОГО не-терминального» → «кроме пре-источникового
`catched`»).
## 2. Тесты
- [x] 2.1 `internal/worker/review_test.go``Defer` из `catched` возвращает
`ErrConflict`, состояние остаётся `catched`; контроль — `Defer` из `review`
по-прежнему уводит в `deferred` (существующий `TestDefer`).
- [x] 2.2 `internal/store/transition_test.go` — обновить инвариант `deferred`:
легальная цель из каждого не-терминального состояния, КРОМЕ `catched`
самопереход `deferred`); добавить проверку, что `catched → deferred` не
легально.
## 3. Спека
- [x] 3.1 MODIFIED-требование «Команды ревью и их эффекты» в `review`;
`openspec validate --strict` зелёный.
## 4. Проверка
- [x] 4.1 `task test` и `task lint` зелёные; существующие тесты
worker/httpapi/store не сломаны.
@@ -0,0 +1,132 @@
## Context
`display_name` и поля блоков «Распознано как» / «инфо источника» — косметика
отображения, не влияющая на пути и раскладку. Слоистое разрешение полей ярлыка
(первый непустой слой `override``recognition`+матч → **контекст**) уже
реализовано, но заперто в неэкспортируемом `worker.effectiveDisplayName`
(`internal/worker/review.go:1145`), который:
1. читает `naming.Fields` из `download.parsed_context` (нижний слой «контекст»);
2. для каждого поля берёт первый непустой слой: `plan.Title→ctxf.Title`,
`plan.Director→ctxf.Director`, `plan.Year→ctxf.Year`, сводка сезонов
`recognize.SeasonSummary(plan)→ctxf.SeasonLabel()`;
3. рендерит строку `naming.Label(...)`.
Билдеры вью строят режиссёра **в обход** этой логики — прямо `rd.Plan.Director`
(`internal/httpapi/review.go:122`), а на странице загрузки
(`internal/httpapi/download.go`) поле вообще захардкожено прочерком в шаблоне.
Итог — слой контекста теряется, и в заголовке страницы режиссёр есть, а в поле —
прочерк.
Constraints (инварианты): вывод имени НИКОГДА не валит приём (деградация к
пустому); `parsed_context` недоверен (битый JSON → пустой слой, best-effort);
`director` — недоверенное косметическое поле, чистится на рендере (`naming.Label`
уже санитизирует); без сети и без изменения схемы БД.
## Goals / Non-Goals
**Goals:**
- Один источник истины для слоистого разрешения полей ярлыка — экспортируемая
функция в `naming`, переиспользуемая в `effectiveDisplayName` и в обоих
билдерах вью.
- Режиссёр на `/download/{id}` согласован с режиссёром в заголовке страницы.
- Поведение `display_name` не меняется (побайтно тот же результат) — существующие
тесты `worker`/`naming` зелёные.
**Non-Goals:**
- Не меняем слои Title/Year/Season в блоке «Распознано как»: они остаются из
плана (что распознано), как сейчас — расширение слоёв контекста на эти поля вне
scope (это изменило бы отображение блока). Правится только режиссёр — поле,
которое даже в норме приходит не из LLM, а из матча/override/контекста.
- Не трогаем схему БД, метабазы, распознавание, раскладку.
## Decisions
### D1. Сигнатура: `naming.EffectiveFields(parsedContext string, plan recognize.Plan)`
Функция принимает **строку `parsed_context`**, а не `store.Download`. Так `naming`
не тянет зависимость на `store` (пакет `naming` — низкоуровневый вывод имени; уже
знает про `parsed_context` концептуально — его схема это `naming.Fields`). Импорт
`naming``recognize` добавляется (нужен `recognize.Plan` и
`recognize.SeasonSummary`); цикла нет — `recognize` не импортирует `naming`
(проверено).
Возвращает не строку, а тип полей:
```go
// LabelFields — эффективные скалярные поля ярлыка после слоистого разрешения.
// Значения уже очищены (sanitize): управляющие символы вырезаны, пробелы
// схлопнуты — те же, что попадут внутрь Label. Season — готовая строка-сводка
// сезонов (для фильма пусто).
type LabelFields struct {
Title string
Director string
Year int
Season string
}
func (f LabelFields) Label() string { return Label(f.Title, f.Director, f.Year, f.Season) }
func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields
```
`Season` — строка, а не `*int`: сводка плана многосезонна («Сезоны 1–3»,
«Спецвыпуски»), в `*int` не выражается. Метод `Label()` даёт единый рендер, чтобы
`effectiveDisplayName` остался тонкой обёрткой.
**Санитайзинг на возврате.** Выбор слоя (первый непустой) идёт по **сырым**
значениям (`plan.Director != ""` и т.д. — как в исходном `effectiveDisplayName`),
но выбранное значение возвращается уже прогнанным через `naming.sanitize`
пакете `naming` он доступен). Причина — билдеры вью печатают `.Director` напрямую
(`{{.Director}}`), в обход `Label`; без очистки на возврате (а) недоверенный
`parsed_context.director` дошёл бы до страницы без санитайзинга, (б) режиссёр из
одних управляющих символов дал бы `{{if .Director}}` истинным в поле, тогда как
`Label` его выбросил бы из заголовка — то самое рассогласование «в поле есть, в
шапке нет». `sanitize` идемпотентен, поэтому повторная очистка внутри `Label`
ничего не меняет: `Label()` даёт **побайтно** прежний `display_name` (тесты
`worker`/`naming` зелёные).
Альтернатива (вариант A из беклога — присвоить `view.Director = rd.Plan.Director`
на странице загрузки) отклонена: оставляет ровно тот баг (нет слоя контекста →
нестыковка с заголовком) и не чинит `review.go`.
### D2. `worker.effectiveDisplayName` — тонкая обёртка
```go
func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
return naming.EffectiveFields(d.ParsedContext, plan).Label()
}
```
Логика перенесена дословно, поэтому результат идентичен. Обёртку и её тесты
(`displayname_test.go`) сохраняем — они продолжают проверять инвариант «имя не
изменилось».
### D3. Билдеры вью читают `.Director`
- `download.go`: новое поле `downloadDetailView.Director`; в `buildDownloadView`
(внутри `if rd.Recognition != nil`) —
`view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director`.
- `review.go`: `view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director`
вместо `rd.Plan.Director`.
Только `Director` берётся из слоистого разрешения; Title/Year/Season в блоке
остаются как есть (см. Non-Goals).
### D4. Шаблон
`download_main.html:40` — прочерк заменяется на условный вывод по конвенции
соседних полей: `{{if .Director}}{{.Director}}{{else}}<span class="faint">—</span>{{end}}`.
Деградация без JS сохраняется (страница server-rendered, поле статично).
## Risks / Trade-offs
- Импорт `naming``recognize` расширяет зависимости пакета `naming`. Приемлемо:
цикла нет, `recognize` — доменный тип плана, а `naming` уже оперирует его
сводкой сезонов косвенно (через worker). Альтернатива — дублировать логику
разрешения в трёх местах — хуже (дрейф).
- Незначительный: `EffectiveFields` парсит `parsed_context` на каждый рендер
страницы. Это дешёвый `json.Unmarshal` короткой строки, страница и так
server-rendered без БД на рендере блока — некритично.
@@ -0,0 +1,60 @@
## Why
На странице просмотра загрузки `/download/{id}` в блоке «Распознано как» поле
«Режиссёр» всегда показано прочерком — оно захардкожено
(`web/templates/partials/download_main.html:40`), в отличие от экрана ревью, где
режиссёр уже выводится. Данные есть: `Plan.Director` персистится в
`recognition.plan`, а страница получает эффективный план через
`Reviewer.ReviewData`. Возникает видимая нестыковка: в заголовке страницы
(`display_name`) режиссёр из контекста уже присутствует, а в поле блока — прочерк.
Причина глубже одного шаблона: полное **слоистое разрешение** полей ярлыка
(override → распознавание+матч → **контекст**) заперто в неэкспортируемом
`worker.effectiveDisplayName`, который отдаёт готовую строку, а не поля. Поэтому
билдеры вью на странице загрузки и на экране ревью видят только слой
«распознавание+матч» (`rd.Plan.Director`) без нижнего слоя контекста — та же дыра
в `internal/httpapi/review.go`.
## What Changes
- **Слоистое разрешение полей ярлыка выносится в экспортируемую функцию**
`naming.EffectiveFields(parsedContext, plan)`, возвращающую эффективные поля
(в т.ч. `Director`), а не готовую строку. Логика — та же, что была в
`effectiveDisplayName`: первый непустой слой `override``recognition`(+матч),
уже свёрнутый в план, с фолбэком на извлечённый из контекста слой
(`parsed_context`).
- **`worker.effectiveDisplayName` переиспользует новую функцию** (тонкая обёртка
`naming.EffectiveFields(...).Label()`) — поведение `display_name` не меняется,
существующие тесты остаются зелёными.
- **Страница загрузки** выводит режиссёра из слоистого разрешения — согласованно
с режиссёром в заголовке (`display_name`); при отсутствии во всех слоях —
прочерк.
- **Экран ревью** берёт режиссёра из той же функции — закрывается та же дыра
(ранее только `rd.Plan.Director`, без слоя контекста).
Всё перечисленное — косметика отображения: не влияет на пути файлов,
распознавание или раскладку.
## Capabilities
### New Capabilities
(нет — правка отображения в существующих capability)
### Modified Capabilities
- `web-ui`: блок «Распознано как» на `/download/{id}` показывает режиссёра
эффективного источника (слоистое разрешение), согласованно с заголовком.
- `review`: инфо-часть выбранного источника показывает режиссёра из слоистого
разрешения (с фолбэком на сохранённый контекст), а не только из плана.
## Impact
- Код: `internal/naming` (экспорт `EffectiveFields` + тип полей с методом
`Label`), `internal/worker` (`effectiveDisplayName` делегирует в naming),
`internal/httpapi` (`download.go`, `review.go` — режиссёр из слоистого
разрешения), `web/templates/partials/download_main.html` (поле «Режиссёр»
вместо прочерка).
- БД: изменений схемы нет.
- Внешние вызовы: нет.
- Беклог: закрывается `docs/backlog/rezhisser-v-kartochke-zagruzki.md`.
@@ -0,0 +1,49 @@
# review Specification
## MODIFIED Requirements
### Requirement: Инфо и предпросмотр выбранного источника
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, режиссёра эффективного источника,
разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при
приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном
слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один
сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и
**предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части
SHALL относиться именно к активному источнику и SHALL обновляться при смене
выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать
хардлинки: раскладка создаётся только явным действием «Применить». Совпадение
целевых путей предпросмотра с результатом применения регулируется требованием
«Превью раскладки через единую логику именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
- **GIVEN** в списке активен кандидат метабазы
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
сериала) именно этого источника и предпросмотр его целевых путей
#### Scenario: Просмотр блока не создаёт раскладку
- **GIVEN** экран ревью с показанным блоком выбора источника
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
«Применить»
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Режиссёр показан, когда доступен
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр этого источника
- **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или
прочерком), не ломая вёрстку
#### Scenario: Режиссёр берётся из контекста, когда матч его не даёт
- **GIVEN** активный источник без режиссёра в плане, но с режиссёром в
сохранённом контексте (`parsed_context`)
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр из контекста (нижний слой разрешения)
@@ -0,0 +1,40 @@
# web-ui Specification
## ADDED Requirements
### Requirement: Режиссёр в блоке распознавания страницы загрузки
Страница просмотра `/download/{id}` в блоке «Распознано как» SHALL показывать
режиссёра эффективного источника, разрешённого теми же слоями, что и
отображаемое имя раздачи (`display_name`): первый непустой слой `override`
`recognition`+матч → сохранённый при приёме контекст (`parsed_context`).
Разрешение режиссёра для поля блока и для отображаемого имени SHALL идти **единой
логикой** (общий источник разрешения), а не расходящимися путями — прежняя
захардкоженная в поле заглушка-прочерк при непустом режиссёре в заголовке
устраняется. Показанное значение SHALL проходить ту же очистку (санитайзинг
управляющих символов/пробелов), что и режиссёр внутри отображаемого имени, чтобы
присутствие/отсутствие режиссёра в поле и в заголовке определялось одинаково.
Когда режиссёр недоступен ни в одном слое, поле SHALL показывать прочерк, не
ломая вёрстку.
#### Scenario: Режиссёр из распознавания показан в блоке
- **GIVEN** загрузка, чей эффективный план несёт режиссёра (из матча метабазы или
закреплённого источника)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в блоке «Распознано как» в поле «Режиссёр» показан этот режиссёр
#### Scenario: Режиссёр из контекста при распознавании без матча
- **GIVEN** загрузка без режиссёра в плане, но с режиссёром в сохранённом
контексте (`parsed_context`)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в поле «Режиссёр» показан режиссёр из контекста
- **AND** он разрешён тем же нижним слоем контекста, что и режиссёр в
отображаемом имени раздачи (единая логика, не расходящиеся пути)
#### Scenario: Режиссёр неизвестен — прочерк
- **GIVEN** загрузка, для которой режиссёр не разрешается ни одним слоем
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
@@ -0,0 +1,37 @@
## 1. Экспортируемое слоистое разрешение (naming)
- [x] 1.1 `internal/naming`: тип `LabelFields` (Title/Director/Year/Season string)
с методом `Label()`, зовущим существующий `Label(...)`
- [x] 1.2 `internal/naming`: функция `EffectiveFields(parsedContext string, plan
recognize.Plan) LabelFields` — перенести логику слоёв из
`worker.effectiveDisplayName` (план → контекст; сводка сезонов плана →
контекстный `SeasonLabel`); выбор слоя по сырым значениям, выбранные скаляры
на возврате прогнать через `sanitize` (идемпотентно — `Label()` даёт прежний
результат); битый `parsed_context` → пустой слой
- [x] 1.3 Юнит-тесты `naming.EffectiveFields`: режиссёр/год/сезон из плана;
фолбэк на контекст; матч бьёт контекст; битый `parsed_context`; пустые
значения; режиссёр из управляющих символов/лишних пробелов → `.Director`
очищен и `.Label()` совпадает с прежним `display_name`
## 2. Переиспользование (worker)
- [x] 2.1 `worker.effectiveDisplayName` → тонкая обёртка
`naming.EffectiveFields(d.ParsedContext, plan).Label()`; поведение и тесты
`displayname_test.go` не меняются
## 3. Вывод режиссёра в вью (httpapi)
- [x] 3.1 `download.go`: поле `downloadDetailView.Director`; в `buildDownloadView`
заполнить из `naming.EffectiveFields(d.ParsedContext, rd.Plan).Director`
- [x] 3.2 `review.go`: `view.Director` из `naming.EffectiveFields(rd.Download.
ParsedContext, rd.Plan).Director` вместо `rd.Plan.Director`
- [x] 3.3 `web/templates/partials/download_main.html`: поле «Режиссёр» —
`{{if .Director}}…{{else}}<span class="faint">—</span>{{end}}` вместо прочерка
## 4. Проверки и закрытие
- [x] 4.1 (опц.) httpapi-тест: режиссёр присутствует во вью страницы загрузки,
если инфраструктура тестов позволяет
- [x] 4.2 `task test` и `task lint` зелёные; `openspec validate --strict`
- [x] 4.3 Удалить `docs/backlog/rezhisser-v-kartochke-zagruzki.md` и строку в
индексе беклога `docs/backlog/README.md`
+107 -7
View File
@@ -154,6 +154,66 @@ downloading` (см. «Переходы состояний сериализуют
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене). параллельной отмене).
Вывод имени (LLM) занимает секунды и идёт вне блокировки, поэтому загрузку могут
отменить (`catched → cancelled`) в это окно. Чтобы отменённая задача не оставила
неуправляемый торрент в qBittorrent, worker SHALL применять комбинированную
защиту. Порядок шагов относительно блокировки переходов: `[под блокировкой]`
re-read состояния → `[вне блокировки]` свежий листинг присутствия → `[вне
блокировки]` `add``[под блокировкой]` запись перехода и (при неуспехе) re-read
состояния для решения об уборке → `[вне блокировки]` удаление. Сетевые вызовы
(листинг, `add`, удаление) под блокировкой держаться SHALL NOT.
- **Re-read состояния перед `add`.** Непосредственно перед `qbt.Add` (после
вывода имени) worker SHALL под блокировкой переходов перечитать запись и, если
она уже НЕ в `catched` (отменена), НЕ вызывать `add` и загрузку в этот тик
пропустить. Это сужает окно гонки до промежутка между re-read и записью
перехода.
- **Подтверждение отсутствия торрента перед `add`.** Непосредственно перед `add`
worker SHALL свежим листингом раздач qBittorrent подтвердить, что раздачи ни с
одним из infohash загрузки ещё НЕТ. Если этот листинг **не удался** (сетевой
сбой), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить (повтор
на следующем): без подтверждённого отсутствия признак «своё/чужое» неизвестен,
и последующее удаление-с-данными было бы небезопасным. Если торрент уже
присутствует (внешний клиент/пользователь добавил тот же infohash в окно
гонки), worker `add` выполнять SHALL NOT и загрузку в этот тик пропустить — на
следующем тике её усыновит ветка «уже присутствует». Подтверждённое отсутствие
непосредственно-перед-`add` SHALL служить признаком того, что торрент,
оказавшийся под этим infohash сразу после `add`, создан именно этим `add` (наш
артефакт), а не пред-существовал.
- **Уборка добавленного торрента при отмене в окне после `add`.** Если `add`
прошёл успешно, а последующая запись перехода `PromoteCatched` не применилась,
worker SHALL принимать решение об уборке по **свежему re-read состояния под
блокировкой**, а не по факту ошибки промоушена: неуспех промоушена бывает и
из-за отмены (`state` уже не `catched`), и из-за транзиентного сбоя хранилища
(`state` всё ещё `catched`, задача жива). Только при подтверждённом `state !=
catched` worker SHALL удалить только что добавленный торрент из qBittorrent
**вместе с его данными** (`deleteFiles = true`) по infohash загрузки. Если
повторное чтение показало `state == catched` (транзиентный сбой) либо само не
удалось, worker торрент удалять SHALL NOT — переход доводится на следующем тике
усыновлением присутствующей (нашей) раздачи. Удаление SHALL идти через API
qBittorrent (`torrents/delete`), не прямыми fs-операциями. Это легитимная уборка
**собственного** артефакта, а не пользовательских данных: инвариант «источник
неприкосновенен» защищает существующие раздачи/файлы пользователя под
`paths.downloads`, а здесь удаляется торрент, который сам worker добавил
секундами ранее — уже после намерения отмены. Состояние отменённой задачи
(`cancelled`) уборка трогать SHALL NOT; неуспех удаления SHALL логироваться
(торрент временно остаётся, повторная авто-уборка не требуется).
- **Негативный инвариант (удаляем только своё).** Удаление-с-данными допустимо
ТОЛЬКО для торрента, который worker создал именно этим `add`. Торрент, который
присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал тот же
infohash / внешний торрент с тем же хешем), удалять с данными worker SHALL NOT —
иначе снёс бы чужие данные в нарушение инварианта. Гарантию обеспечивает
подтверждение отсутствия перед `add`: путь уборки достижим только тогда, когда
отсутствие infohash было подтверждено непосредственно перед `add`; при
обнаруженном присутствии (или недоступном листинге) `add` не выполняется вовсе.
Записи об этом пути (торрент оставлен после отмены → удаляем; факт удаления) worker
SHALL логировать на уровне `WARN` с корреляцией по `download_id`/`infohash` и без
секретов; неуспех удаления — на `ERROR`.
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent #### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента - **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
@@ -196,14 +256,54 @@ downloading` (см. «Переходы состояний сериализуют
- **THEN** загрузка остаётся в `catched` - **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется - **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления #### Scenario: Свежий листинг перед add недоступен — повтор
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне - **GIVEN** загрузка в `catched`, торрента в снимке тика нет, имя выведено
блокировки - **WHEN** свежий листинг присутствия непосредственно перед `add` не удался
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем (сетевой сбой)
worker берёт блокировку для записи перехода - **THEN** worker `add` НЕ вызывает (отсутствие infohash не подтверждено)
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в - **AND** загрузка остаётся в `catched`, попытка повторяется на следующем тике
`downloading` не применяется
#### Scenario: Отмена до add — источник не добавляется
- **GIVEN** загрузка в `catched`, worker выводит отображаемое имя вне блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`) во время
вывода имени, а затем worker перечитывает состояние перед `add`
- **THEN** re-read видит, что загрузка уже не в `catched`, и `qbt.Add` НЕ
вызывается
- **AND** источник в qBittorrent не добавляется, задача остаётся `cancelled`
#### Scenario: Отмена в окне после add — добавленный торрент удаляется с данными
- **GIVEN** загрузка в `catched`, отсутствие её infohash в qBittorrent
подтверждено перед `add`, и `add` прошёл успешно
- **WHEN** отмена (`catched → cancelled`) приходит в окне между `add` и записью
перехода, из-за чего запись перехода не применяется, а re-read состояния под
блокировкой показывает `state != catched`
- **THEN** worker удаляет только что добавленный торрент из qBittorrent вместе с
его данными (`deleteFiles = true`) по infohash загрузки
- **AND** пишет `WARN` о том, что торрент оставлен после отмены и удалён
- **AND** состояние задачи остаётся `cancelled`
#### Scenario: Сбой записи перехода без отмены — торрент не удаляется
- **GIVEN** загрузка в `catched`, `add` прошёл успешно, но запись перехода
`PromoteCatched` вернула ошибку из-за транзиентного сбоя хранилища
- **WHEN** re-read состояния под блокировкой показывает, что загрузка всё ещё в
`catched` (отмены не было)
- **THEN** worker торрент из qBittorrent НЕ удаляет (это наш живой торрент)
- **AND** переход доводится на следующем тике усыновлением присутствующей раздачи
#### Scenario: Пред-существующий торрент не удаляется с данными
- **GIVEN** загрузка в `catched`, чей infohash уже присутствует в qBittorrent к
моменту проверки перед `add` (внешний торрент/раздача пользователя с тем же
хешем)
- **WHEN** worker обрабатывает тик и параллельно приходит отмена
- **THEN** worker `add` НЕ выполняет и торрент с данными НЕ удаляет (чужие данные
неприкосновенны)
- **AND** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
на следующем тике, если задача ещё активна)
### Requirement: Предохранитель зависшего catched ### Requirement: Предохранитель зависшего catched
+55
View File
@@ -228,3 +228,58 @@ recognition(is_current)` плюс проверка существования п
- **WHEN** строится план раскладки новой загрузки с этим матчем - **WHEN** строится план раскладки новой загрузки с этим матчем
- **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла - **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла
### Requirement: Пересканирование Jellyfin после изменения библиотечных ссылок
При сконфигурированном пересканировании Jellyfin (секция `[jellyfin]` включена) система SHALL при входе задачи в одно из состояний множества `{done, reverted, deleted}` **неблокирующе** просить Jellyfin пересканировать медиатеку (`POST /Library/Refresh`, скан всех библиотек). Эти три состояния — точки, где раскладка задачи **улеглась** так, что видимый Jellyfin каталог мог рассинхронизироваться с диском: `done` — наши хардлинки разложены (или восстановлены сверкой); `reverted` — Undo снял наши ссылки; `deleted` — ссылки сняты (Delete) либо констатировано их отсутствие (сверка), задача терминальна.
Условие срабатывания система SHALL проверять **по состоянию-цели перехода** в
едином чекпоинте записи состояния. Такой гейт SHALL естественно покрывать как
пользовательские команды (Undo → `reverted`, Delete → `deleted`), так и
reconcile-производный `deleted` — инициатор перехода роли не играет; повторный/
лишний скан безвреден (инкрементальный скан дёшев, операция идемпотентна).
Состояния **вне** этого множества система сканировать SHALL NOT. Сюда входят как
входы, не меняющие наши ссылки (`review`, `linking`, `cancelled` через Dismiss),
так и **промежуточные состояния рассинхрона** `target_missing` и `orphaned`: там
раскладка ещё не улеглась — задача ждёт relink/восстановления и может
«залечиться» обратно в `done`, поэтому скан на них система откладывает, а не шлёт
на каждое колебание сверки. `target_missing` система не сканирует сознательно,
хотя цель там пропала: это внешняя пропажа при живом источнике, не наше снятие.
Скан система SHALL выполнять **вне** блокировки воркера, в фоновом контексте и в
отдельной горутине, со scoped-логгером задачи для корреляции. Недоступность
Jellyfin на состояние задачи влиять SHALL NOT — ошибка вызова лишь логируется
(её пишет клиент Jellyfin как запись внешнего вызова). Если пересканирование не
сконфигурировано (`[jellyfin]` выключено), скан не дёргается ни в одном из этих
переходов.
#### Scenario: Скан после раскладки
- **GIVEN** пересканирование Jellyfin включено
- **WHEN** задача входит в `done` после успешной раскладки хардлинков
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Скан после отката (Undo)
- **GIVEN** пересканирование Jellyfin включено, задача в `done` с разложенными ссылками
- **WHEN** пользователь выполняет Undo и задача входит в `reverted` (наши ссылки сняты)
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Скан после удаления (Delete)
- **GIVEN** пересканирование Jellyfin включено, задача в `done`
- **WHEN** пользователь выполняет Delete и задача входит в `deleted` (наши ссылки сняты)
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
#### Scenario: Без конфигурации Jellyfin скан не дёргается
- **GIVEN** пересканирование Jellyfin выключено (`[jellyfin]` не сконфигурировано)
- **WHEN** задача входит в `done`, `reverted` или `deleted`
- **THEN** система скан не дёргает
#### Scenario: Вход вне множества не сканирует
- **GIVEN** пересканирование Jellyfin включено
- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
- **THEN** система скан не дёргает
+48 -11
View File
@@ -39,6 +39,17 @@ movie↔series: тип показывается read-only, а его корре
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда. команда.
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ
добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю
сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched`
единственное состояние без раздачи среди не-терминальных: откладывать в нём
нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в
лимбо — `processCatched` листает только `catched` и больше её не подхватит, а
последующие команды через отсутствие источника выводят необратимый `deleted`.
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
дебаунса) проверять перед действием, что источник не только присутствует в дебаунса) проверять перед действием, что источник не только присутствует в
@@ -74,6 +85,23 @@ qBittorrent, но и **готов к раскладке** — раздача в
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет - **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника - **AND** тип показан read-only в инфо-части выбранного источника
#### Scenario: Позже паркует задачу из ревью
- **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть)
- **WHEN** пользователь выбирает «Позже»
- **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по
любому последующему действию
#### Scenario: Позже отклоняется для пре-источникового catched
- **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent)
- **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на
`/ui/downloads/{id}/defer`)
- **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить
можно только после добавления торрента
- **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через
`processCatched`
#### Scenario: Недокачанный источник отклоняет перераспознавание #### Scenario: Недокачанный источник отклоняет перераспознавание
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё - **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
@@ -256,15 +284,17 @@ NOT проваливать команду ревью. Это согласует
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series), (активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, режиссёра (из подтверждённого матча/ название, оригинальное название, год, режиссёра эффективного источника,
кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала — разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при
сводку сезонов (один сезон, диапазон/список для многосезонного пака или приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном
«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один
источника. Обе части SHALL относиться именно к активному источнику и SHALL сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и
обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST **предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части
NOT создавать хардлинки: раскладка создаётся только явным действием «Применить». SHALL относиться именно к активному источнику и SHALL обновляться при смене
Совпадение целевых путей предпросмотра с результатом применения регулируется выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать
требованием «Превью раскладки через единую логику именования» (`web-ui`). хардлинки: раскладка создаётся только явным действием «Применить». Совпадение
целевых путей предпросмотра с результатом применения регулируется требованием
«Превью раскладки через единую логику именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику #### Scenario: Инфо и предпросмотр относятся к активному источнику
@@ -286,8 +316,15 @@ NOT создавать хардлинки: раскладка создаётся
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра - **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
- **WHEN** отображается инфо-часть выбранного источника - **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр этого источника - **THEN** в ней показан режиссёр этого источника
- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не - **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или
ломая вёрстку прочерком), не ломая вёрстку
#### Scenario: Режиссёр берётся из контекста, когда матч его не даёт
- **GIVEN** активный источник без режиссёра в плане, но с режиссёром в
сохранённом контексте (`parsed_context`)
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней показан режиссёр из контекста (нижний слой разрешения)
### Requirement: Разделение труда транспортов в ревью ### Requirement: Разделение труда транспортов в ревью
+38 -1
View File
@@ -511,7 +511,6 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
(бейдж, имя, живой прогресс) (бейдж, имя, живой прогресс)
- **AND** поллинг фазы `catched` завершается - **AND** поллинг фазы `catched` завершается
### Requirement: Загрузка .torrent-файла на форме добавления ### Requirement: Загрузка .torrent-файла на форме добавления
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
@@ -541,3 +540,41 @@ htmx-путь (список обновляется/происходит реди
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст - **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
- **WHEN** форма отправлена - **WHEN** форма отправлена
- **THEN** выполняется приём по тексту источника, как прежде - **THEN** выполняется приём по тексту источника, как прежде
### Requirement: Режиссёр в блоке распознавания страницы загрузки
Страница просмотра `/download/{id}` в блоке «Распознано как» SHALL показывать
режиссёра эффективного источника, разрешённого теми же слоями, что и
отображаемое имя раздачи (`display_name`): первый непустой слой `override`
`recognition`+матч → сохранённый при приёме контекст (`parsed_context`).
Разрешение режиссёра для поля блока и для отображаемого имени SHALL идти **единой
логикой** (общий источник разрешения), а не расходящимися путями — прежняя
захардкоженная в поле заглушка-прочерк при непустом режиссёре в заголовке
устраняется. Показанное значение SHALL проходить ту же очистку (санитайзинг
управляющих символов/пробелов), что и режиссёр внутри отображаемого имени, чтобы
присутствие/отсутствие режиссёра в поле и в заголовке определялось одинаково.
Когда режиссёр недоступен ни в одном слое, поле SHALL показывать прочерк, не
ломая вёрстку.
#### Scenario: Режиссёр из распознавания показан в блоке
- **GIVEN** загрузка, чей эффективный план несёт режиссёра (из матча метабазы или
закреплённого источника)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в блоке «Распознано как» в поле «Режиссёр» показан этот режиссёр
#### Scenario: Режиссёр из контекста при распознавании без матча
- **GIVEN** загрузка без режиссёра в плане, но с режиссёром в сохранённом
контексте (`parsed_context`)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в поле «Режиссёр» показан режиссёр из контекста
- **AND** он разрешён тем же нижним слоем контекста, что и режиссёр в
отображаемом имени раздачи (единая логика, не расходящиеся пути)
#### Scenario: Режиссёр неизвестен — прочерк
- **GIVEN** загрузка, для которой режиссёр не разрешается ни одним слоем
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
+1 -1
View File
@@ -37,7 +37,7 @@
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd> <dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
{{if .IsSeries}}<dt>Сезон</dt><dd>{{if .Season}}{{.Season}}{{else}}<span class="faint"></span>{{end}}</dd>{{end}} {{if .IsSeries}}<dt>Сезон</dt><dd>{{if .Season}}{{.Season}}{{else}}<span class="faint"></span>{{end}}</dd>{{end}}
<dt>Год</dt><dd>{{if .Year}}{{.Year}}{{else}}<span class="faint"></span>{{end}}</dd> <dt>Год</dt><dd>{{if .Year}}{{.Year}}{{else}}<span class="faint"></span>{{end}}</dd>
<dt>Режиссёр</dt><dd><span class="faint"></span></dd> <dt>Режиссёр</dt><dd>{{if .Director}}{{.Director}}{{else}}<span class="faint"></span>{{end}}</dd>
<dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}<span class="faint"></span>{{end}}</dd> <dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}<span class="faint"></span>{{end}}</dd>
<dt>Уверенность</dt><dd class="mono">{{if .Confidence}}{{.Confidence}}{{else}}<span class="faint"></span>{{end}}</dd> <dt>Уверенность</dt><dd class="mono">{{if .Confidence}}{{.Confidence}}{{else}}<span class="faint"></span>{{end}}</dd>
</dl> </dl>