Compare commits
9
Commits
0354a8c96b
...
50f29b56aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f29b56aa
|
||
|
|
659fa5ec44
|
||
|
|
3a00fde058
|
||
|
|
d02d88f49a
|
||
|
|
098695011f
|
||
|
|
91de53b8f1
|
||
|
|
0ced0e18a5
|
||
|
|
5948f4d219
|
||
|
|
1639ebfdd7
|
@@ -30,15 +30,12 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
|
||||
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
|
||||
- [Бэкап 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 проверяет только сам сервис
|
||||
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
|
||||
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
|
||||
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
|
||||
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
|
||||
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
|
||||
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 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)_
|
||||
|
||||
## Низкий
|
||||
|
||||
@@ -60,3 +57,4 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
||||
- [Современный 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)_
|
||||
- [Нити приёма: 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]].
|
||||
@@ -181,10 +181,15 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
||||
|
||||
## Пересканирование Jellyfin
|
||||
|
||||
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
|
||||
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
|
||||
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не
|
||||
дёргается.
|
||||
Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
|
||||
пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
|
||||
новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
|
||||
ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
|
||||
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
|
||||
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
|
||||
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
|
||||
relink/лечения. Включается конфигом `[jellyfin]` (по умолчанию выключено); без
|
||||
него скан не дёргается.
|
||||
|
||||
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
|
||||
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
|
||||
@@ -260,9 +265,9 @@ Dockerfile .dockerignore config.example.toml
|
||||
задач (повторная закачка спустя время → новая задача).
|
||||
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
|
||||
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
|
||||
- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан
|
||||
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
|
||||
включается `[jellyfin]`.
|
||||
- Пересканирование Jellyfin при изменении наших ссылок — `POST /Library/Refresh`
|
||||
(скан всех библиотек, инкрементальный), неблокирующе на входе в `done`/
|
||||
`reverted`/`deleted`; опц., включается `[jellyfin]`.
|
||||
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
|
||||
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
|
||||
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
|
||||
|
||||
@@ -100,7 +100,9 @@ stateDiagram-v2
|
||||
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
||||
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки) и
|
||||
**Удалить** → `deleted` (полное удаление, см. ниже).
|
||||
**Удалить** → `deleted` (полное удаление, см. ниже). Скан дёргается и при
|
||||
входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
|
||||
держать битые пути.
|
||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||
(ретраибельна); «Отклонить».
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
@@ -38,6 +39,7 @@ type downloadDetailView struct {
|
||||
OriginalTitle string
|
||||
Season string // сводка сезонов для сериала (пусто для фильма)
|
||||
Year int
|
||||
Director string // режиссёр эффективного источника (пусто — неизвестен)
|
||||
Provider string
|
||||
ProviderID string
|
||||
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
||||
@@ -145,6 +147,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
view.Season = recognize.SeasonSummary(rd.Plan)
|
||||
}
|
||||
view.Year = rd.Plan.Year
|
||||
// Режиссёр — из того же слоистого разрешения, что и display_name в шапке
|
||||
// (override → распознавание+матч → контекст), чтобы поле не расходилось с
|
||||
// заголовком; пустой во всех слоях → прочерк в шаблоне.
|
||||
view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director
|
||||
// Ручное обновление имени доступно, когда есть распознанное название,
|
||||
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
|
||||
view.Nameable = rd.Plan.Title != ""
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"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 проверяет отдачу встроенной статики с кэш-заголовком.
|
||||
func TestStaticServed(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"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/store"
|
||||
"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.Title = rd.Plan.Title
|
||||
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
|
||||
if view.IsSeries {
|
||||
view.SeasonSummary = recognize.SeasonSummary(rd.Plan)
|
||||
|
||||
@@ -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: "Фарго. Сезоны 1–3",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,14 @@ package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
)
|
||||
|
||||
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
|
||||
@@ -45,6 +47,61 @@ type Fields struct {
|
||||
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» для сериала с
|
||||
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
|
||||
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
|
||||
|
||||
@@ -78,9 +78,10 @@ func (s State) IsTerminal() bool {
|
||||
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
|
||||
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
|
||||
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
|
||||
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
|
||||
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
|
||||
// аккуратностью.
|
||||
// - deferred — легальная цель из каждого не-терминального состояния, КРОМЕ
|
||||
// пре-источникового catched (Defer его отклоняет: раздачи в qBittorrent ещё
|
||||
// нет, откладывать нечего, а catched → deferred увёл бы задачу в лимбо —
|
||||
// MAJOR-6). Инвариант закреплён тестом, а не ручной аккуратностью.
|
||||
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
|
||||
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
|
||||
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
|
||||
@@ -90,7 +91,7 @@ func (s State) IsTerminal() bool {
|
||||
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
|
||||
// setState отклонит переход (0 строк UPDATE → ошибка).
|
||||
var allowedTransitions = map[State][]State{
|
||||
StateCatched: {StateDownloading, StateFailed, StateCancelled, StateDeferred},
|
||||
StateCatched: {StateDownloading, StateFailed, StateCancelled},
|
||||
StateDownloading: {StateCompleted, StateFailed, StateStuck, StateCancelled, StateDeferred},
|
||||
StateCompleted: {StateRecognizing, StateCancelled, StateDeferred},
|
||||
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
|
||||
|
||||
@@ -74,18 +74,25 @@ func TestTransitionGraphWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
|
||||
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
|
||||
// состояние» (напр. linking после краха процесса).
|
||||
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
|
||||
// Инвариант Defer: deferred — легальная цель из каждого не-терминального
|
||||
// состояния, КРОМЕ пре-источникового catched (Defer его отклоняет: раздачи в
|
||||
// qBittorrent ещё нет, откладывать нечего — MAJOR-6) и самого deferred (это
|
||||
// самопереход). Ловит класс дыры «забыли состояние» (напр. linking после краха
|
||||
// процесса).
|
||||
func TestDeferReachableFromEveryNonTerminalButCatched(t *testing.T) {
|
||||
for _, s := range allStates {
|
||||
if s.IsTerminal() || s == StateDeferred {
|
||||
continue // deferred → deferred покрыт самопереходом
|
||||
if s.IsTerminal() || s == StateDeferred || s == StateCatched {
|
||||
continue // deferred → deferred покрыт самопереходом; catched исключён
|
||||
}
|
||||
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 — легальная цель из
|
||||
|
||||
@@ -214,9 +214,10 @@ func TestProcessCatchedTimeoutFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
|
||||
// переход в downloading не применяется — состояние остаётся cancelled.
|
||||
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
|
||||
// F3: отмена во время (медленного) вывода имени видна re-read'ом состояния ПЕРЕД
|
||||
// Add — источник в qBittorrent не добавляется вовсе (раньше Add успевал пройти,
|
||||
// оставляя неуправляемый торрент без владельца).
|
||||
func TestProcessCatchedCancelledDuringNamerSkipsAdd(t *testing.T) {
|
||||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
@@ -226,11 +227,122 @@ func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if len(qb.added) != 1 {
|
||||
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
|
||||
if len(qb.added) != 0 {
|
||||
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 {
|
||||
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
@@ -136,8 +136,9 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
return res, savePath, nil
|
||||
}
|
||||
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу: при
|
||||
// уверенном матче (Decision.Auto) и чистой валидации — авто-раскладка, иначе —
|
||||
// в review (см. ветвление ниже).
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) {
|
||||
log := logctx.From(ctx)
|
||||
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() {
|
||||
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())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
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
|
||||
}
|
||||
|
||||
// effectiveDisplayName собирает полный ярлык display_name из эффективных полей:
|
||||
// плана (override → распознавание+матч, уже свёрнуто effectivePlan) с fallback на
|
||||
// сохранённый контекст (parsed_context) для полей, которых план не дал (например
|
||||
// режиссёр из контекста при распознавании без матча). Сводка сезонов —
|
||||
// recognize.SeasonSummary(plan) с fallback на контекстный скаляр. Единый формат с
|
||||
// шагом добавления (общий naming.Label). parsed_context недоверен: битый JSON →
|
||||
// пустой слой (best-effort), очистка полей — на рендере.
|
||||
// effectiveDisplayName собирает полный ярлык display_name из эффективных полей.
|
||||
// Тонкая обёртка над naming.EffectiveFields (слоистое разрешение план →
|
||||
// контекст) + Label — единый источник разрешения полей ярлыка, общий со
|
||||
// страницей загрузки и экраном ревью.
|
||||
func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
|
||||
var ctxf naming.Fields
|
||||
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)
|
||||
return naming.EffectiveFields(d.ParsedContext, plan).Label()
|
||||
}
|
||||
|
||||
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
|
||||
|
||||
@@ -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 {
|
||||
d := completedDownload(id)
|
||||
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-библиотеками и исходными файлами.
|
||||
type applyFixture struct {
|
||||
w *Worker
|
||||
|
||||
@@ -476,6 +476,38 @@ func (w *Worker) processCatched(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
if addErr != nil {
|
||||
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
|
||||
@@ -484,16 +516,42 @@ func (w *Worker) processCatched(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Успех: короткий переход под w.mu с ре-валидацией state=catched
|
||||
// Успех Add: короткий переход под w.mu с ре-валидацией state=catched
|
||||
// (загрузку могли отменить, пока шли сетевые вызовы).
|
||||
w.mu.Lock()
|
||||
if err := w.store.PromoteCatched(cctx, d.ID, rename); err != nil {
|
||||
logctx.From(cctx).Info("catched promote skipped", "reason", err.Error())
|
||||
} else {
|
||||
perr := w.store.PromoteCatched(cctx, d.ID, rename)
|
||||
if perr == nil {
|
||||
logctx.From(cctx).Info("state transition", "from", store.StateCatched,
|
||||
"to", store.StateDownloading)
|
||||
}
|
||||
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 пересканировать библиотеку, чтобы
|
||||
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
|
||||
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
|
||||
if w.scanner != nil && state == store.StateDone {
|
||||
// Наши библиотечные хардлинки изменились — просим Jellyfin пересканировать
|
||||
// библиотеку, чтобы плеер не держал битые пути и быстрее подхватил новые
|
||||
// файлы. Триггерят входы, где раскладка «улеглась»: done (ссылки разложены),
|
||||
// reverted (Undo снял ссылки), deleted (Delete снял / сверка констатировала
|
||||
// отсутствие). target_missing/orphaned — промежуточный рассинхрон, ждём
|
||||
// relink/лечения, не сканируем. Неблокирующе и вне w.mu; недоступность
|
||||
// Jellyfin не влияет на состояние задачи.
|
||||
if w.scanner != nil && triggersScan(state) {
|
||||
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
||||
@@ -849,6 +911,20 @@ func (w *Worker) transitionErr(ctx context.Context, d store.Download, state stor
|
||||
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 дебаунсит повторные уведомления о падении одной задачи
|
||||
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
|
||||
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
|
||||
@@ -24,6 +24,7 @@ type fakeStore struct {
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
@@ -182,6 +183,9 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if f.promoteErr != nil {
|
||||
return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
|
||||
}
|
||||
if d.State != store.StateCatched {
|
||||
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
|
||||
}
|
||||
@@ -281,8 +285,10 @@ type fakeQbt struct {
|
||||
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
|
||||
files []qbt.File
|
||||
deleted [][]string // хеши каждого вызова Delete
|
||||
deletedData []bool // deleteFiles каждого вызова Delete (параллельно deleted)
|
||||
deleteErr error
|
||||
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
|
||||
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 {
|
||||
if f.onAdd != nil {
|
||||
f.onAdd()
|
||||
}
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
@@ -328,11 +337,12 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
|
||||
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 {
|
||||
return f.deleteErr
|
||||
}
|
||||
f.deleted = append(f.deleted, hashes)
|
||||
f.deletedData = append(f.deletedData, deleteFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+57
@@ -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 на состояние задачи не влияет.
|
||||
Источник неприкосновенен — скан лишь читает библиотеку.
|
||||
+56
@@ -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** система скан не дёргает
|
||||
+34
@@ -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:447–453);
|
||||
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`.
|
||||
+214
@@ -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`
|
||||
@@ -154,6 +154,66 @@ 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`, торрента
|
||||
@@ -196,14 +256,54 @@ downloading` (см. «Переходы состояний сериализуют
|
||||
- **THEN** загрузка остаётся в `catched`
|
||||
- **AND** на следующем тике попытка добавления повторяется
|
||||
|
||||
#### Scenario: Отмена во время добавления
|
||||
#### Scenario: Свежий листинг перед add недоступен — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||
блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||
worker берёт блокировку для записи перехода
|
||||
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||
`downloading` не применяется
|
||||
- **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** загрузка пропускается в этот тик (усыновление присутствующей раздачи —
|
||||
на следующем тике, если задача ещё активна)
|
||||
|
||||
### Requirement: Предохранитель зависшего catched
|
||||
|
||||
|
||||
@@ -228,3 +228,58 @@ recognition(is_current)` плюс проверка существования п
|
||||
- **WHEN** строится план раскладки новой загрузки с этим матчем
|
||||
- **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** система скан не дёргает
|
||||
|
||||
|
||||
@@ -39,6 +39,17 @@ movie↔series: тип показывается read-only, а его корре
|
||||
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
|
||||
команда.
|
||||
|
||||
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
|
||||
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
|
||||
отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ
|
||||
добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю
|
||||
сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` —
|
||||
единственное состояние без раздачи среди не-терминальных: откладывать в нём
|
||||
нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в
|
||||
лимбо — `processCatched` листает только `catched` и больше её не подхватит, а
|
||||
последующие команды через отсутствие источника выводят необратимый `deleted`.
|
||||
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
|
||||
|
||||
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
||||
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
|
||||
дебаунса) проверять перед действием, что источник не только присутствует в
|
||||
@@ -74,6 +85,23 @@ qBittorrent, но и **готов к раскладке** — раздача в
|
||||
- **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 ещё
|
||||
@@ -256,15 +284,17 @@ NOT проваливать команду ревью. Это согласует
|
||||
|
||||
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
|
||||
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
|
||||
название, оригинальное название, год, режиссёра (из подтверждённого матча/
|
||||
кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала —
|
||||
сводку сезонов (один сезон, диапазон/список для многосезонного пака или
|
||||
«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого
|
||||
источника. Обе части SHALL относиться именно к активному источнику и SHALL
|
||||
обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST
|
||||
NOT создавать хардлинки: раскладка создаётся только явным действием «Применить».
|
||||
Совпадение целевых путей предпросмотра с результатом применения регулируется
|
||||
требованием «Превью раскладки через единую логику именования» (`web-ui`).
|
||||
название, оригинальное название, год, режиссёра эффективного источника,
|
||||
разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при
|
||||
приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном
|
||||
слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один
|
||||
сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и
|
||||
**предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части
|
||||
SHALL относиться именно к активному источнику и SHALL обновляться при смене
|
||||
выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать
|
||||
хардлинки: раскладка создаётся только явным действием «Применить». Совпадение
|
||||
целевых путей предпросмотра с результатом применения регулируется требованием
|
||||
«Превью раскладки через единую логику именования» (`web-ui`).
|
||||
|
||||
#### Scenario: Инфо и предпросмотр относятся к активному источнику
|
||||
|
||||
@@ -286,8 +316,15 @@ NOT создавать хардлинки: раскладка создаётся
|
||||
- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра
|
||||
- **WHEN** отображается инфо-часть выбранного источника
|
||||
- **THEN** в ней показан режиссёр этого источника
|
||||
- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не
|
||||
ломая вёрстку
|
||||
- **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или
|
||||
прочерком), не ломая вёрстку
|
||||
|
||||
#### Scenario: Режиссёр берётся из контекста, когда матч его не даёт
|
||||
|
||||
- **GIVEN** активный источник без режиссёра в плане, но с режиссёром в
|
||||
сохранённом контексте (`parsed_context`)
|
||||
- **WHEN** отображается инфо-часть выбранного источника
|
||||
- **THEN** в ней показан режиссёр из контекста (нижний слой разрешения)
|
||||
|
||||
### Requirement: Разделение труда транспортов в ревью
|
||||
|
||||
|
||||
@@ -511,7 +511,6 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
|
||||
(бейдж, имя, живой прогресс)
|
||||
- **AND** поллинг фазы `catched` завершается
|
||||
|
||||
|
||||
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||
|
||||
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
|
||||
@@ -541,3 +540,41 @@ htmx-путь (список обновляется/происходит реди
|
||||
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
|
||||
- **WHEN** форма отправлена
|
||||
- **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** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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}}
|
||||
<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 class="mono">{{if .Confidence}}{{.Confidence}}{{else}}<span class="faint">—</span>{{end}}</dd>
|
||||
</dl>
|
||||
|
||||
Reference in New Issue
Block a user