Беклог: перенос четырёх задач из инбокса Tududi

Инбокс Tududi разгружен: четыре открытые задачи проекта оформлены файлами
в docs/backlog и помечены выполненными в Tududi.

- режиссёр в блоке «Распознано как» на странице загрузки (средний);
- карточка информации о торренте вылезает за экран на мобильном (средний);
- сигнал Jellyfin после отката и удаления файлов (средний);
- кнопки выглядят как кнопки: btn-ghost и рассогласование весов (низкий).

Каждая задача проверена по коду: все четыре актуальны, ни одну не закрыли
прошлые коммиты. В телах — точки правки, развилки и нужные дельты спек.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-15 21:02:22 +03:00
co-authored by Claude Opus 4.8
parent 02d4ecc2aa
commit 3e470e3ae6
5 changed files with 233 additions and 0 deletions
+4
View File
@@ -30,6 +30,9 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи) - [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт… - [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture - [Бэкап SQLite](backup-sqlite.md) — architecture
- [Сигнал Jellyfin после отката и удаления файлов](jellyfin-skan-posle-udaleniya.md) — Скан шлётся только на done; после reverted/deleted Jellyfin держит битые записи. Клиент и гейт готовы, но скана нет в openspec-спеках
- [Режиссёр в блоке «Распознано как» на странице загрузки](rezhisser-v-kartochke-zagruzki.md) — На /download/{id} режиссёр всегда прочерк (поля нет в шаблоне); экран ревью его уже выводит
- [Карточка информации о торренте вылезает за границу экрана на мобильном](mobilnaya-verstka-kv-infohash.md) — grid-blowout на .kv из-за неразрывного infohash — страница получает горизонтальный скролл
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис - [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг) - [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать… - [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
@@ -40,6 +43,7 @@ Tududi (проект `jellybit`) больше **не** держит беклог
## Низкий ## Низкий
- [Кнопки выглядят как кнопки: `btn-ghost` и рассогласование весов](unifikaciya-knopok-btn-ghost.md) — «Откатить» неотличима от текста: btn-ghost прозрачен, аффорданс только на hover; плюс одно действие имеет разный вес в списке и на странице
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи - [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов - [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает… - [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
@@ -0,0 +1,59 @@
# Сигнал 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` надо дописать.
@@ -0,0 +1,56 @@
# Карточка информации о торренте вылезает за границу экрана на мобильном
**Приоритет:** средний
На узком экране блок «Информация о торренте» на `/download/{id}` выталкивает
страницу за вьюпорт — появляется горизонтальный скролл всей страницы.
## Причина
Grid-blowout на `.kv` из-за неразрывного infohash:
- `web/templates/partials/download_main.html:83-86``<dl class="kv">` с
`<dd class="mono">{{.}}</dd>` для каждого инфохэша.
- `web/static/css/jellybit.css:448-451``.kv{grid-template-columns:auto 1fr}`,
у `.kv dd` **нет** `overflow-wrap`/`word-break`/`min-width:0`. Трек `1fr` =
`minmax(auto,1fr)`, его автоминимум = min-content = весь 40-символьный хэш.
Арифметика на 360px: доступно ≈302px, требуется ≈380px (`dt` «infohash» nowrap
≈52px + gap 16px + хэш ≈312px) → переполнение ~78px. `overflow-x:hidden` на
`body`/`html` нигде нет, поэтому блок физически ломает страницу.
Недавние коммиты про ellipsis (`44c5952` — только `.btn-file`) и `.type-ico`
(`3f80d36`) этот блок не трогали.
## Что уже защищено (не виновники)
- `jellybit.css:283-285``.src-full{word-break:break-all}` → magnet уже чинится.
- `jellybit.css:365-366``.table-wrap{overflow-x:auto}` + `table.tbl{min-width:560px}`
→ таблица раскладки скроллится внутри себя. Страницу не ломает, но 560px
принудительного горизонтального скролла на мобильном — отдельная заноза.
- `.fname`/`.link-to` (`:374`, `:378-379`), `.card-title` (`:205`),
`.review-title` (`:298`) — `word-break` есть.
## Развилки
- **(а) точечно:** `.kv dd{overflow-wrap:anywhere;min-width:0}` +
`.kv dd.mono{word-break:break-all}`.
- **(б) в медиа-запросе** перевести `.kv` в одну колонку
(`grid-template-columns:1fr`, `dd{text-align:left}`) — заодно снимает
проблему `dt{white-space:nowrap}`.
Учесть: `.kv` используется **и** в блоке «Распознано как»
(`download_main.html:34-43`) — правка затронет оба, то есть чинится и потенциально
длинный `RecTitle`. Страховку `body{overflow-x:hidden}` как единственную меру не
брать — маскирует, а не лечит.
Мобильных стилей почти нет: единственный `@media (max-width:640px)`
`jellybit.css:477-492` (14 правил), ни `.kv`, ни `.table-wrap` в нём не упомянуты.
## Спеки
`openspec/specs/web-ui/spec.md:237-247` («Блок информации о торренте») требований
к адаптивности не содержит → либо чистый bugfix, либо дельта с требованием
переноса длинных значений.
Связано: [[web-ui-pwa]] (крупная переделка, этот фикс — точечный).
@@ -0,0 +1,50 @@
# Режиссёр в блоке «Распознано как» на странице загрузки
**Приоритет:** средний
На странице `/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]].
@@ -0,0 +1,64 @@
# Кнопки выглядят как кнопки: `btn-ghost` и рассогласование весов
**Приоритет:** низкий
Кнопка «Откатить» в списке загрузок неотличима от текста/ссылки. Это **не**
разметка — там уже `<button type="submit">` внутри `<form>`; проблема чисто
визуальная, в классе `btn-ghost`:
- `web/static/css/jellybit.css:165-166` — `.btn-ghost{background:transparent;
border-color:transparent;color:var(--text-dim)}`. Прозрачный фон + прозрачный
бордер + приглушённый текст = в покое нет аффорданса. Он появляется только на
`:hover`, а на тач-устройствах hover'а нет вообще.
## Инвентаризация
| Действие | Список (`card.html`) | Страница (`download_main.html`) |
|---|---|---|
| Ревью | `:28` `btn btn-primary btn-sm` | `:21` `btn btn-primary` |
| **Откатить** | `:29` **`btn btn-sm btn-ghost`** | `:23` **`btn btn-danger`** «↩ Откатить» |
| Привязать заново | `:30` `btn btn-sm` | `:24` `btn` |
| Повторить | `:31` `btn btn-sm` | `:25` `btn` |
| **Отменить/Отклонить** | `:32` **`btn btn-sm btn-danger`** | `:26` **`btn btn-ghost`** |
| Обновить имя | — | `:22` `btn btn-ghost` |
Расхождения:
1. `btn-ghost` — корень проблемы, 3 места: `card.html:29`,
`download_main.html:22`, `download_main.html:26`.
2. «Откатить» несогласован сам с собой: невидимая `btn-ghost` в списке против
красной `btn-danger` на странице.
3. «Отменить» инвертирован ровно наоборот: `btn-danger` в списке против
`btn-ghost` на странице. В списке Откатить тише Отменить, на странице — наоборот.
4. Эмодзи-префиксы несогласованы: `↩ Откатить` (страница) vs `Откатить` (список);
`review_main.html:76-78` — эмодзи на всех (`🕗/❌/✅`), `card.html` — ни одного.
5. Вероятно ок: `review_main.html:54,58`, `review_source_block.html:49`,
`index.html:32,65-67` (пагинация — ссылка-как-кнопка законно),
`download_main.html:112,124` (danger-zone).
6. `.copy-btn` (`card.html:9`, `download_main.html:15,90`) — отдельная система
классов (`jellybit.css:210-214`), не `.btn`. Формально вне скоупа, но при
унификации всплывёт.
База CSS — `jellybit.css:153-171`.
## Развилки
- **(а)** дать `.btn-ghost` видимый бордер/фон — одна строка, чинит все 3 места.
- **(б)** выкинуть `.btn-ghost` и назначить семантику явно: Откатить = `btn-danger`
везде, Отменить = `btn-danger` везде, Обновить имя = нейтральный `.btn`.
- **(в)** решить судьбу `` — оставляем или убираем.
Рекомендация — б+в: (а) чинит видимость, но оставляет пункты 2-4 (одно действие
имеет два разных визуальных веса на разных экранах).
**Подводный камень:** `.btn-ghost` портирован из макета (`tmp/design/index.html:109`
— тоже `btn btn-sm btn-ghost` для «Откатить», а `tmp/design/download.html:43` —
`btn btn-danger`). Макет сам непоследователен → либо чинить и `tmp/design/*`, либо
явно зафиксировать, что макет больше не источник истины (см. `tmp/design/handoff.md:96-97`).
## Спеки
`openspec/specs/web-ui/spec.md:249-264` («Действия соответствуют состоянию»)
описывает, какие действия показывать и имена полей форм; требований к визуальной
стилизации нет. «Откатить» упоминается только в htmx-сценариях (`:435`, `:444`).
→ чистый nit-фикс либо новая дельта, если хотим зафиксировать семантику весов.