Compare commits

...
6 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 0354a8c96b Беклог: чистка четырёх закрытых задач (mobilnaya-kv, btn-ghost, F7–F10, lifecycle-minor)
Реализованы и влиты; MINOR-9 (I/O под глобальным w.mu) осознанно waived
для one-user home-сервера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:37:58 +03:00
avandClaude Opus 4.8 06a0e1ce41 OpenSpec: архив change retry-reject-broken-torrent (синк state-reconciliation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:36:04 +03:00
avandClaude Opus 4.8 bcc7b2d76b Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента
Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable
2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для
one-user home-сервера — не трогаем.

MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на
момент claim (перечитывая запись после перехода в recognizing), а
finishRecognition коммитит результат, только если токен совпал. Иначе за
время LLM-вызова задачу увели из recognizing и вернули обратно
(cancel → relink revive) — это уже другой эпизод, устаревший результат
отбрасываем, задача остаётся в recognizing для перезапуска поллингом.

NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют
усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes
зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих
infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины.

NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь
отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо
бессмысленной переотдачи источника (сверка тут же вернула бы задачу в
failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет
спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent
(не архивировал).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 b8017d65eb Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md):

- F7: oversized .torrent через веб отдавал 500. Введён sentinel
  ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400.
- F8: гонка fast-path attach с cancel. Пред-рид
  FindReingestBlockingByInfohash больше не короткозамыкает активную
  запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
  под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных
  desync-записей (target_missing/orphaned). F6-апгрейд сохранён.
- F9: magnet — регистронезависимый URN-префикс xt (RFC 2141);
  tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным
  matchем.
- F10: cap контекста до 16 KiB в ingest.Ingest (единственное место
  слияния — покрывает все транспорты), рунобезопасная обрезка + маркер.
- N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в
  U+FFFD.

Добавлены юнит-тесты на каждое исправленное поведение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 17d9f23630 Веб-UI: единый вес кнопок, убрана невидимая btn-ghost из применений
Класс .btn-ghost прозрачен (нет аффорданса без hover, на тач невидим), плюс
веса действий рассогласованы между списком (card.html) и страницей
(download_main.html). Назначаем семантику весов явно:

- «Откатить» = btn-danger везде (в списке был btn-ghost); в список добавлен
  префикс ↩, чтобы список и страница совпадали.
- «Отменить/Отклонить» = btn-danger везде (на странице был btn-ghost).
- «Обновить имя» = нейтральный .btn (был btn-ghost).

Определение .btn-ghost в jellybit.css оставлено на случай легитимного
использования — правились только применения. Косметический nit-фикс, htmx-пути
и деградация без JS не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
avandClaude Opus 4.8 bd7bf60b33 Веб-UI: перенос длинных значений .kv (фикс горизонтального скролла на мобильном)
Блок «Информация о торренте» на /download/{id} на узком экране выталкивал
страницу за вьюпорт: grid-blowout на `.kv` (grid-template-columns:auto 1fr)
из-за неразрывного 40-символьного infohash — дефолтный min-width:auto грид-
элемента не давал колонке 1fr сжаться уже min-content хэша.

Точечный фикс (вариант «а»): `.kv dd{overflow-wrap:anywhere;min-width:0}` +
`.kv dd.mono{word-break:break-all}`. Правка штатно чинит и потенциально
длинный RecTitle в блоке «Распознано как» (тот же `.kv`). На обычной ширине
вёрстка не меняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:35:14 +03:00
28 changed files with 597 additions and 217 deletions
-4
View File
@@ -32,7 +32,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Бэкап 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 проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
- [Обучение на правках человека (few-shot из прошлых ревью)](obuchenie-na-pravkah.md) — Когда человек поправил матч, тип или нумерацию — сохранять это как пример и подмешивать…
@@ -43,7 +42,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
## Низкий
- [Кнопки выглядят как кнопки: `btn-ghost` и рассогласование весов](unifikaciya-knopok-btn-ghost.md) — «Откатить» неотличима от текста: btn-ghost прозрачен, аффорданс только на hover; плюс одно действие имеет разный вес в списке и на странице
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
- [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) — просто и работает…
@@ -61,6 +59,4 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Авторизация веб-UI (на будущее)](avtorizaciya-web-ui.md) — Решено для v1: без авторизации в доверенной LAN, опц
- [Современный 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)_
- [Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)](review-f7-f10-ingest-ui-fixes.md) — мелочи приёма/UI: oversized→500, гонки дедупа, парсинг magnet, cap контекста, руны _(ревью 2026-07-08)_
- [Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)](review-lifecycle-minor.md) — claim-token распознавания, I/O под глобальным mutex, мелкие lookup/retry _(ревью 2026-07-08)_
- [Нити приёма: NoName в контексте, устаревшие комментарии, лог без причины, bencode-аллокации (N1, N3, N4, N5)](review-ingest-nits.md) — косметика приёма: NoName в контексте, устаревшие комментарии, лог, bencode-аллокации _(ревью 2026-07-08)_
@@ -1,56 +0,0 @@
# Карточка информации о торренте вылезает за границу экрана на мобильном
**Приоритет:** средний
На узком экране блок «Информация о торренте» на `/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]] (крупная переделка, этот фикс — точечный).
@@ -1,17 +0,0 @@
# Приём/UI: мелкие фиксы границ и парсинга (F7, F8, F9, F10, N2)
**Приоритет:** низкий · **Теги:** ingest, review-2026-07-08
Ревью Fable 2026-07-08 (приём). Пачка независимых простых фиксов.
F7 — oversized .torrent через веб → 500 вместо 400. ingest.go:148-151 отдаёт plain fmt.Errorf «torrent too large», classifyErr (httpapi.go:753-770) → default → 500. Web MaxBytesReader пропускает MaxTorrentSize+1MiB. Фикс: sentinel-ошибка размера → 400. (Telegram ок — pre-check doc.FileSize.)
F8 — fast-path attach гонка с cancel. ingest.go:85-90,205-217: FindActiveByInfohash (без tx) вернул catched, параллельно cancel → attached() отдаёт Deduplicated=true (stale «уже в работе»), ничего не активно. Фикс: убрать ранний return, дедуп-решение только в CreateDownloadIfNoActive (уже re-check под BEGIN IMMEDIATE).
F9 — magnet parsing. magnet.go:46-51: HasPrefix «urn:btih:» регистрозависим → magnet:?xt=URN:BTIH:… отклоняется (RFC 2141: URN регистронезависим; scheme уже матчится EqualFold :91). tgbot/parse.go:11: magnet:\?[^\s]+ приклеивает хвостовую пунктуацию (точка/скобка) → xt последним → длина 41 → отказ. Фикс: case-insensitive префикс; trim хвостовых .,;:)]}>» в ParseMessage.
F10 — cap размера context из веб-формы. httpapi.go:405,412-414: multipart-бюджет на всё тело; без файла поле context ~9MiB → download.context в БД, рендер, LLM-промпты. REST 64KiB, Telegram лимит подписи — открыт только web. Фикс: cap Context в Ingest (одно место) ~16KiB с маркером.
N2 — shorten() режет по байтам не рунам (httpapi.go:713-718): кириллические source_ref-заголовки (частый случай) рвутся посреди руны → U+FFFD. Фикс: рунобезопасная обрезка.
Вердикт: все простые фиксы.
-15
View File
@@ -1,15 +0,0 @@
# Жизненный цикл: мелкие находки (MINOR-8, MINOR-9, NIT-11, NIT-12)
**Приоритет:** низкий · **Теги:** review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (жизненный цикл). Мелкие находки.
MINOR-8 — recognition claim-token отсутствует. review.go:160-177: задача в recognizing, LLM в полёте (вне w.mu); user Cancel→Relink (recognizing→cancelled→recognizing revive). Старый вызов завершается, finishRecognition re-check d.State==recognizing — true, но это НОВЫЙ claim → устаревший результат коммитится (recognizing→review); свежий прогон отбрасывает себя. Импакт низкий (входы почти идентичны), но потерянный LLM-вызов + результат приписан не той попытке. Фикс: claim-token (updated_at на момент claim, или id строки recognition).
MINOR-9 — I/O под глобальным w.mu. worker.go:677-734 (Retry: torrentByInfohash + qbt.Add под mu), review.go: ensureSourcePresent (сеть) в Relink/Rerecognize/Refine/SetType, Apply (torrentByInfohash + layouter.Apply FS I/O), Undo (FS I/O) — держат w.mu целиком. Нарушает своё же правило «медленные вызовы вне блокировки» (processCatched/recognizeOne его соблюдают). Медленный/зависший qBit → каждый клик ревью = глобальный стопор поллинга и всех команд транспортов. Корректность ок (lock даёт race-free preflight), только liveness. Для one-user home-сервера — можно осознанно waive; худший — Retry с qbt.Add под mu при зависшем qBit. Фикс если делать: probe-вне-lock + re-validate-под-lock (как processCatched).
NIT-11 — byHash индексирует и усечённый 40-hex t.Hash v2-only торрентов (worker.go:444-452). Хранение усечённых хешей отклоняется (discover.go:98-111), но lookup-мапа всё ещё ключует t.Hash → v1-хеш одной задачи теоретически == усечённый-v2 t.Hash другого торрента → матч не тому. Требует 160-бит коллизию (космологические шансы). Фикс: исключить t.Hash из мапы, когда есть infohash_v1/v2 (зеркалить torrentHashes).
NIT-12 — Retry на failed/qbit_error (missingFiles) с живым errored-торрентом: alive=true → без re-Add → downloading → следующий тик classErrored → снова failed (+ дебаунс уведомления). Честно, но user-hostile: retry выглядит сломанным, реальное лекарство (recheck/fix в qBit) не подсказано. Фикс: отклонять retry (или форсить recheck) когда живой торрент classErrored. (Связано с задачей retry/stall семантики.)
Вердикт: MINOR-8/NIT-11/NIT-12 — простые фиксы; MINOR-9 — change или осознанный waive.
@@ -1,64 +0,0 @@
# Кнопки выглядят как кнопки: `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-фикс либо новая дельта, если хотим зафиксировать семантику весов.
+20
View File
@@ -140,3 +140,23 @@ func TestIndexCardMeta(t *testing.T) {
t.Error("контекст всё ещё показан в карточке списка")
}
}
// N2: shorten режет по рунам, не байтам — кириллица (2 байта/руна) не рвётся
// посреди символа в U+FFFD.
func TestShortenRuneSafe(t *testing.T) {
// 50 кириллических рун (100 байт). Обрезка до 40 рун раньше резала s[:40]
// посреди руны.
s := strings.Repeat("я", 50)
got := shorten(s, 40)
if strings.ContainsRune(got, '') {
t.Errorf("обрезка порвала руну: %q", got)
}
// 40 рун + многоточие.
if want := strings.Repeat("я", 40) + "…"; got != want {
t.Errorf("shorten = %q, want %q", got, want)
}
// Короткая строка (по рунам) возвращается как есть, без многоточия.
if got := shorten("привет", 40); got != "привет" {
t.Errorf("короткая строка изменена: %q", got)
}
}
+11 -3
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -724,11 +725,14 @@ func desyncNote(s store.State) string {
}
}
// shorten обрезает строку до n рун (не байт), добавляя многоточие. Рунобезопасно:
// кириллица (2 байта/руна) иначе резалась бы посреди руны в U+FFFD — частый случай
// для source_ref-заголовков.
func shorten(s string, n int) string {
if len(s) <= n {
if utf8.RuneCountInString(s) <= n {
return s
}
return s[:n] + "…"
return string([]rune(s)[:n]) + "…"
}
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
@@ -761,7 +765,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404; валидация источника
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput)
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
@@ -772,6 +777,9 @@ func classifyErr(err error) (int, string) {
return http.StatusNotFound, "не найдено"
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, ingest.ErrTorrentTooLarge):
// Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500.
return http.StatusBadRequest, "файл .torrent слишком большой"
case errors.Is(err, worker.ErrInvalidInput):
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
// не сбой сервера.
+16
View File
@@ -148,6 +148,22 @@ func TestAPIAddBadInput(t *testing.T) {
}
}
// F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400
// (промах ввода), а не 500.
func TestAPIAddTorrentTooLarge(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestAPIList(t *testing.T) {
reader := &fakeReader{list: []store.Download{
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
+52 -8
View File
@@ -8,9 +8,11 @@ package ingest
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/magnet"
@@ -89,17 +91,21 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
ctx = logctx.With(ctx, log)
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
// Быстрый пред-рид по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
// активная задача ЛИБО удерживающая источник desync-запись
// (target_missing/orphaned) блокируют повторный приём. Для активной
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
// размыть инвариант «≤1 активной»).
// (target_missing/orphaned) блокируют повторный приём. Здесь короткозамыкаем
// ТОЛЬКО desync-запись (терминальную): её CreateDownloadIfNoActive не увидит
// (тот проверяет лишь активных), а состояния она не меняет. Активную же НЕ
// короткозамыкаем — этот чек без транзакции, и в гонке с параллельным cancel
// вернул бы stale «уже в работе» при пустом активном множестве. Авторитетное
// дедуп-решение по активной примет CreateDownloadIfNoActive под BEGIN IMMEDIATE.
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err)
} else if existing != nil {
} else if existing != nil && existing.State.IsTerminal() {
// FindReingestBlockingByInfohash отдаёт терминальную запись только из
// удерживающих desync-состояний (target_missing/orphaned) — присоединяемся.
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
return s.attached(ctx, src, existing), nil
}
@@ -111,7 +117,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
d := &store.Download{
SourceType: src.sourceType,
SourceRef: src.sourceRef,
Context: mergeContext(req.Context, src.synthContext),
Context: capContext(mergeContext(req.Context, src.synthContext)),
State: store.StateCatched,
}
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
@@ -144,6 +150,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// файлов → много piece-хешей) отсекаются здесь.
const MaxTorrentSize = 8 << 20 // 8 MiB
// ErrTorrentTooLarge — принятый `.torrent` превышает MaxTorrentSize. Это промах
// ввода пользователя (норма, не сбой сервера), поэтому транспорт транслирует его
// в 400, а не 500 (веб MaxBytesReader пропускает файлы чуть больше лимита —
// отсекает уже приём). Проверяется через errors.Is.
var ErrTorrentTooLarge = errors.New("torrent too large")
// parsedSource — нормализованный источник приёма (magnet или .torrent).
type parsedSource struct {
sourceType store.SourceType
@@ -159,7 +171,7 @@ type parsedSource struct {
func (s *Service) parse(req Request) (parsedSource, error) {
if len(req.TorrentData) > 0 {
if len(req.TorrentData) > MaxTorrentSize {
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes: %w", len(req.TorrentData), MaxTorrentSize, ErrTorrentTooLarge)
}
info, err := torrent.Parse(req.TorrentData)
if err != nil {
@@ -195,6 +207,38 @@ func (s *Service) parse(req Request) (parsedSource, error) {
}, nil
}
// MaxContextSize — предел размера контекста распознавания (пользовательский текст
// + синтез из полей источника). Кап здесь, на единственном месте слияния,
// покрывает все транспорты: REST ограничен телом (64 KiB), Telegram — лимитом
// подписи, но веб-форма (multipart-бюджет на всё тело) иначе пропустила бы
// мегабайты в поле context → в БД, рендер карточки и LLM-промпты.
const MaxContextSize = 16 << 10 // 16 KiB
// contextTruncMarker дописывается к усечённому контексту как явный маркер.
const contextTruncMarker = "\n…[контекст усечён]"
// capContext ограничивает контекст MaxContextSize байтами, обрезая по границе
// руны (кириллица — 2 байта/руна; обрезка посреди руны дала бы U+FFFD) и добавляя
// маркер усечения. Пустой/короткий контекст возвращается как есть.
func capContext(s string) string {
if len(s) <= MaxContextSize {
return s
}
return trimToRune(s[:MaxContextSize]) + contextTruncMarker
}
// trimToRune отбрасывает незавершённую многобайтовую руну на конце строки
// (результат обрезки по фиксированному числу байт), не трогая корректный хвост.
func trimToRune(s string) string {
for len(s) > 0 {
if r, size := utf8.DecodeLastRuneInString(s); r != utf8.RuneError || size > 1 {
break
}
s = s[:len(s)-1]
}
return s
}
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
+85
View File
@@ -2,6 +2,7 @@ package ingest
import (
"context"
"errors"
"io"
"log/slog"
"strings"
@@ -53,6 +54,30 @@ func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string,
return f.upgradeUp, nil
}
// raceStore моделирует гонку F8: пред-рид FindReingestBlockingByInfohash видит
// активную запись (blocking), но create-гард CreateDownloadIfNoActive её уже не
// находит (в параллели отменена) и заводит свежую задачу.
type raceStore struct {
blocking *store.Download
created []store.Download
}
func (r *raceStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
return r.blocking, nil
}
func (r *raceStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, _ []string, _ []byte) (*store.Download, error) {
d.ID = ident.NewID()
r.created = append(r.created, *d)
return nil, nil // активной уже нет — создаём новую
}
func (r *raceStore) AddInfohashes(_ context.Context, _ string, _ []string) error { return nil }
func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _ []byte) (bool, error) {
return false, nil
}
func newService(st Store) *Service {
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
@@ -207,6 +232,66 @@ func TestIngestDedupTopsUpHashes(t *testing.T) {
}
}
// F8: пред-рид FindReingestBlockingByInfohash увидел активную задачу, но к моменту
// создания она отменена (гонка с cancel). Активную запись пред-рид НЕ
// короткозамыкает — авторитетное дедуп-решение принимает CreateDownloadIfNoActive
// под BEGIN IMMEDIATE: активной больше нет → заводим свежую задачу, а не
// возвращаем stale Deduplicated «уже в работе».
func TestIngestActivePreReadNotShortCircuited(t *testing.T) {
stale := &store.Download{ID: "01hzzzstale00000000000000000", State: store.StateCatched}
fs := &raceStore{blocking: stale} // пред-рид видит активную; create-гард — уже нет
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if res.Deduplicated {
t.Errorf("активный пред-рид не должен коротко замыкать дедуп: %+v", res)
}
if res.DownloadID == stale.ID || res.State != store.StateCatched {
t.Errorf("ожидалась свежая задача, а не stale: %+v", res)
}
if len(fs.created) != 1 {
t.Errorf("должна быть создана новая задача, created=%d", len(fs.created))
}
}
// F7: oversized `.torrent` — доменная ошибка размера класса ErrTorrentTooLarge
// (транспорт транслирует в 400, а не 500). Задача не заводится.
func TestIngestRejectsOversizedTorrent(t *testing.T) {
fs := &fakeStore{}
big := make([]byte, MaxTorrentSize+1)
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
if !errors.Is(err, ErrTorrentTooLarge) {
t.Fatalf("err = %v, want ErrTorrentTooLarge", err)
}
if len(fs.created) != 0 {
t.Error("не должно быть записи задачи")
}
}
// F10: контекст из веб-формы может быть огромным (multipart-бюджет на всё тело) —
// Ingest режет его до MaxContextSize по границе руны (без U+FFFD) и метит маркером.
func TestIngestCapsContext(t *testing.T) {
// «Ё» — 2 байта; ASCII-префикс сдвигает границу MaxContextSize на нечётный
// байт, чтобы обрезка s[:MaxContextSize] пришлась ВНУТРЬ двухбайтовой руны —
// тогда trimToRune реально срабатывает (иначе граница попадёт между рунами).
huge := "x" + strings.Repeat("Ё", MaxContextSize)
fs := &fakeStore{}
if _, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: huge}); err != nil {
t.Fatalf("Ingest: %v", err)
}
got := fs.created[0].Context
if len(got) > MaxContextSize+len(contextTruncMarker) {
t.Errorf("контекст не ограничен: %d байт", len(got))
}
if !strings.HasSuffix(got, contextTruncMarker) {
t.Errorf("нет маркера усечения: …%q", got[max(0, len(got)-40):])
}
if strings.ContainsRune(got, '') {
t.Error("обрезка порвала руну (U+FFFD)")
}
}
func TestIngestRejectsNonMagnet(t *testing.T) {
fs := &fakeStore{}
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
+17 -4
View File
@@ -30,6 +30,19 @@ type Info struct {
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
var ErrNotMagnet = errors.New("not a magnet link")
// URN-префиксы xt-топиков. По RFC 2141 namespace-идентификатор URN
// регистронезависим, поэтому сверяем их EqualFold (magnet:?xt=URN:BTIH:… —
// валиден). Сам хеш нормализует по регистру normalizeBTIH/normalizeBTMH.
const (
prefixBTIH = "urn:btih:"
prefixBTMH = "urn:btmh:"
)
// hasURNPrefix сообщает, начинается ли xt с URN-префикса регистронезависимо.
func hasURNPrefix(xt, prefix string) bool {
return len(xt) >= len(prefix) && strings.EqualFold(xt[:len(prefix)], prefix)
}
// Parse разбирает magnet-ссылку. Поддерживаются btih (v1: 40-hex или
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
// предпочитается v1.
@@ -43,12 +56,12 @@ func Parse(raw string) (Info, error) {
var v1, v2 string
for _, xt := range vals["xt"] {
switch {
case strings.HasPrefix(xt, "urn:btih:"):
if h, err := normalizeBTIH(strings.TrimPrefix(xt, "urn:btih:")); err == nil && v1 == "" {
case hasURNPrefix(xt, prefixBTIH):
if h, err := normalizeBTIH(xt[len(prefixBTIH):]); err == nil && v1 == "" {
v1 = h
}
case strings.HasPrefix(xt, "urn:btmh:"):
if h, err := normalizeBTMH(strings.TrimPrefix(xt, "urn:btmh:")); err == nil && v2 == "" {
case hasURNPrefix(xt, prefixBTMH):
if h, err := normalizeBTMH(xt[len(prefixBTMH):]); err == nil && v2 == "" {
v2 = h
}
}
+7
View File
@@ -31,6 +31,13 @@ func TestParse(t *testing.T) {
raw: "magnet:?xt=urn:btmh:12200123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
infohash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
// RFC 2141: namespace URN регистронезависим — URN:BTIH: валиден.
name: "btih с URN-префиксом в верхнем регистре",
raw: "magnet:?xt=URN:BTIH:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&dn=Dune",
infohash: "541adcff3b6dd5dba7088ea83317d9d6fac331d6",
dn: "Dune",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
+9
View File
@@ -13,6 +13,13 @@ var (
parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
)
// magnetTrailingPunct — хвостовая пунктуация, которую жадный magnetRe (`[^\s]+`)
// приклеивает к ссылке, когда magnet стоит в конце предложения или в скобках
// («…magnet:?xt=…).»). Такой хвост сдвигает последний xt и ломает разбор
// (длина btih становится 41 → отказ). TrimRight трактует cutset как множество
// рун, поэтому многобайтовая «»» отсекается корректно.
const magnetTrailingPunct = ".,;:)]}>»"
// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём.
var noisePrefixes = []string{
"Открыть magnet", "или получить .torrent", "Оценить", "Следить",
@@ -29,6 +36,8 @@ func ParseMessage(text string) (source, context string, ok bool) {
if m == "" {
return "", "", false
}
// Срезаем хвостовую пунктуацию, приклеенную жадным матчем (см. константу).
m = strings.TrimRight(m, magnetTrailingPunct)
return m, cleanContext(text, m), true
}
+27
View File
@@ -75,3 +75,30 @@ func TestParseMessage_NoMagnet(t *testing.T) {
t.Error("ожидалось ok=false без magnet")
}
}
// F9: жадный magnetRe приклеивает хвостовую пунктуацию (magnet в конце
// предложения/в скобках) — ParseMessage её срезает, иначе последний xt сдвигается
// и длина btih ломается.
func TestParseMessage_TrimsTrailingPunct(t *testing.T) {
const want = "magnet:?xt=urn:btih:7931AA3ED6666746012F5739D099B5BC64D72A16"
cases := []struct {
name string
text string
}{
{"точка", "Смотри: " + want + "."},
{"скобка", "(" + want + ")"},
{"кавычка-ёлочка", "«" + want + "»"},
{"несколько", want + ").,"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
src, _, ok := ParseMessage(tc.text)
if !ok {
t.Fatal("magnet не найден")
}
if src != want {
t.Errorf("src = %q, want %q", src, want)
}
})
}
}
+24
View File
@@ -110,6 +110,30 @@ func torrentHashes(t qbt.Torrent) []string {
return out
}
// torrentIndexHashes — хеши, под которыми раздачу индексируют lookup-мапы
// (byHash/live) и поиск по infohash. Зеркалит выбор torrentHashes: t.Hash берём
// только когда qBittorrent не отдал infohash_v1/v2 (старый API). У v2-only
// раздачи t.Hash — усечённый до 40 hex v2-хеш; индексировать его нельзя — по длине
// он неотличим от v1 и мог бы ложно сматчить задачу с таким же v1-хешем (NIT-11),
// хотя для этого нужна невозможная на практике 160-битная коллизия. В отличие от
// torrentHashes хеши не нормализует и не дедуплицирует — ключ приводит вызывающий.
func torrentIndexHashes(t qbt.Torrent) []string {
if t.InfohashV1 == "" && t.InfohashV2 == "" {
if t.Hash == "" {
return nil
}
return []string{t.Hash}
}
out := make([]string, 0, 2)
if t.InfohashV1 != "" {
out = append(out, t.InfohashV1)
}
if t.InfohashV2 != "" {
out = append(out, t.InfohashV2)
}
return out
}
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
// быть полноразмерным (torrentHashes усечённые не отдаёт).
+22
View File
@@ -180,6 +180,28 @@ func TestTorrentHashes(t *testing.T) {
}
}
// TestTorrentIndexHashes — NIT-11: lookup-мапы индексируют раздачу по её хешам,
// но усечённый 40-hex t.Hash v2-only торрента исключаем (по длине неотличим от v1).
func TestTorrentIndexHashes(t *testing.T) {
// Гибрид: индексируем оба infohash, t.Hash игнорируем.
got := torrentIndexHashes(qbt.Torrent{Hash: "trunc", InfohashV1: "v1", InfohashV2: "v2"})
if len(got) != 2 || got[0] != "v1" || got[1] != "v2" {
t.Errorf("гибрид: got %v, want [v1 v2] (t.Hash исключён)", got)
}
// Старый qBittorrent без infohash_v1/v2 — берём hash (это полный v1).
if got := torrentIndexHashes(qbt.Torrent{Hash: "legacy"}); len(got) != 1 || got[0] != "legacy" {
t.Errorf("legacy: got %v, want [legacy]", got)
}
// v2-only: усечённый t.Hash не индексируем, только полный v2.
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := torrentIndexHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2}); len(got) != 1 || got[0] != v2 {
t.Errorf("v2-only: got %v, want [%s] (усечённый t.Hash исключён)", got, v2)
}
if got := torrentIndexHashes(qbt.Torrent{}); len(got) != 0 {
t.Errorf("пустой: got %v, want empty", got)
}
}
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
func TestDiscover_AdoptsV2Only(t *testing.T) {
+6 -5
View File
@@ -9,15 +9,16 @@ import (
)
// TestPollBuildsLiveSnapshot: после Poll снимок несёт телеметрию качающейся и
// сидирующей задач (с верным Seeding), доступную по любому из трёх хэшей;
// сидирующей задач (с верным Seeding), доступную по любому из infohash;
// неизвестный/пустой infohash → ok=false.
func TestPollBuildsLiveSnapshot(t *testing.T) {
qb := &fakeQbt{torrents: []qbt.Torrent{
// Качается (Category пуст → discover не усыновляет, store не мешает).
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120, TotalSize: 4096},
// Сидирует, торрент v2 (три ключа).
// Сидирует, гибридный торрент: t.Hash дублирует v1 (как отдаёт qBittorrent),
// индексируем по infohash_v1/v2 (усечённый t.Hash не индексируем — NIT-11).
{
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
Hash: "bbb1", InfohashV1: "bbb1", InfohashV2: "BBB2",
State: "uploading", Progress: 1.0, TotalSize: 8192,
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
},
@@ -42,8 +43,8 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize)
}
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
// Сидирующая задача находится по любому из своих infohash (lowercase).
for _, h := range []string{"bbb1", "BBB2", "bbb2"} {
sd, ok := w.Live(h)
if !ok {
t.Fatalf("нет телеметрии раздачи по ключу %q", h)
+32 -5
View File
@@ -69,7 +69,18 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) {
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
if d.State == store.StateCompleted {
w.transition(ctx, *d, store.StateRecognizing, "", "")
// Перечитываем ради свежего updated_at: он служит claim-токеном этой
// попытки (MINOR-8). Токен фиксирует конкретный recognizing-эпизод; если
// задачу позже уведут из recognizing и вернут обратно (cancel → relink),
// updated_at сменится, и finishRecognition отбросит устаревший результат.
d, err = w.store.GetDownload(ctx, id)
if err != nil {
w.mu.Unlock()
logctx.From(ctx).Warn("recognition reload after claim failed", "error", err)
return
}
}
claim := d.UpdatedAt
w.mu.Unlock()
result, savePath, err := w.runRecognize(ctx, *d)
@@ -81,7 +92,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) {
Reasons: []string{"распознавание не удалось: " + err.Error()},
}}
}
w.finishRecognition(ctx, id, result, savePath)
w.finishRecognition(ctx, id, claim, result, savePath)
}
// runRecognize собирает сигналы из qBittorrent и накопленные подсказки,
@@ -127,7 +138,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) {
log := logctx.From(ctx)
planJSON, err := json.Marshal(res.Plan)
if err != nil {
@@ -169,7 +180,23 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
}
if d.State != store.StateRecognizing {
// За время вызова LLM задачу увели (cancel/defer) — результат не нужен.
log.Info("recognition result discarded", "state", d.State)
log.Info("recognition result discarded", "reason", "state_changed", "state", d.State)
return
}
// Claim-токен (MINOR-8): состояние снова recognizing, но за время вызова LLM
// задачу могли увести из recognizing и вернуть обратно (cancel → relink revive).
// Тогда это УЖЕ другой recognizing-эпизод (updated_at сменился), а наш результат
// принадлежит прежней попытке — отбрасываем. Задача остаётся в recognizing, и
// поллинг-цикл (recognizePending) перезапустит распознавание свежей попыткой.
//
// Токен — updated_at секундной точности; сравниваем на строгое равенство, поэтому
// отбрасываем при ЛЮБОй его смене. В recognizing-эпизоде метку бьют только переходы
// состояния (не-переходные мутации задачи в этом состоянии не идут), так что валидный
// результат ложно не теряется; а редкий холостой сброс безопасен — распознавание
// просто повторится. Остаточное окно — revive целиком в ту же секунду, что и исходный
// claim (человеческий темп cancel→relink это исключает).
if d.UpdatedAt != claim {
log.Info("recognition result discarded", "reason", "stale_claim")
return
}
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
@@ -1405,8 +1432,8 @@ func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.To
want[store.NormalizeHash(h)] = true
}
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" && want[strings.ToLower(h)] {
for _, h := range torrentIndexHashes(t) {
if want[strings.ToLower(h)] {
return t, true, nil
}
}
+35
View File
@@ -315,6 +315,10 @@ type memStore struct {
// Инъекция сбоев (для тестов устойчивости раскладки).
failCreateLinks error // CreateFileLinks вернёт эту ошибку
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
// clock — монотонный счётчик для updated_at (как реальный store бьёт метку на
// каждом переходе); питает проверку claim-токена распознавания (MINOR-8).
clock int
}
func newMemStore() *memStore {
@@ -450,6 +454,8 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
d.State = st
d.ErrorCode = store.NullString(code)
d.ErrorMsg = store.NullString(msg)
m.clock++
d.UpdatedAt = itoa(m.clock) // свежая метка перехода — claim-токен (MINOR-8)
return nil
}
@@ -810,6 +816,35 @@ func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
}
}
// TestRecognizeOne_DiscardsStaleClaim — MINOR-8: если во время вызова LLM задачу
// увели из recognizing и вернули обратно (cancel → relink revive), это уже другой
// recognizing-эпизод (updated_at сменился). Устаревший результат прежнего прогона
// отбрасывается, задача остаётся в recognizing для перезапуска поллингом.
func TestRecognizeOne_DiscardsStaleClaim(t *testing.T) {
st := newMemStore()
st.put(completedDownload("1"))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
}
// Во время вызова LLM задачу уводят из recognizing и возвращают обратно
// (симуляция cancel → relink): состояние снова recognizing, но updated_at сменился.
rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
_ = st.SetDownloadState(context.Background(), "1", store.StateCancelled, "", "")
_ = st.SetDownloadState(context.Background(), "1", store.StateRecognizing, "", "")
}}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), "1")
if st.downloads["1"].State != store.StateRecognizing {
t.Errorf("state = %q, want recognizing (устаревший результат отброшен, ждём перезапуска)", st.downloads["1"].State)
}
if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
t.Error("recognition устаревшей попытки не должна сохраняться")
}
}
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload("1"))
+15 -13
View File
@@ -524,12 +524,10 @@ func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.To
func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent {
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
for _, h := range torrentIndexHashes(t) {
byHash[strings.ToLower(h)] = t
}
}
}
return byHash
}
@@ -585,14 +583,12 @@ func (w *Worker) Poll(ctx context.Context) error {
live := make(map[string]Live, len(torrents)*2)
for _, t := range torrents {
l := liveFrom(t)
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
for _, h := range torrentIndexHashes(t) {
key := strings.ToLower(h)
byHash[key] = t
live[key] = l
}
}
}
// Снимок зависит только от torrents — свопаем сразу, до store-операций
// ниже (их ранний return по ошибке не должен лишать UI свежей телеметрии).
w.setLive(live)
@@ -964,12 +960,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
}
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
// retried_at, поэтому возврат в downloading не роняет задачу снова на ближайшем
// тике (MAJOR-1).
reAdd := true
if hashes := d.HashList(); len(hashes) > 0 {
var t qbt.Torrent
@@ -978,7 +972,15 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
if err != nil {
return fmt.Errorf("retry: %w", err)
}
reAdd = !alive || classify(t.State) == classErrored
if alive && classify(t.State) == classErrored {
// Живой, но сломанный торрент (error/missingFiles): повторный Add его не
// чинит (qBittorrent отверг бы дубль), а простой возврат в downloading
// тут же снова упал бы classErrored на ближайшем тике сверки (+ дебаунс
// уведомления) — retry выглядел бы сломанным. Отклоняем с подсказкой
// починить раздачу в qBittorrent (recheck/восстановить файлы) — NIT-12.
return fmt.Errorf("retry: раздача в qBittorrent в состоянии ошибки (%s) — почини её (recheck) в qBittorrent и повтори: %w", t.State, ErrConflict)
}
reAdd = !alive
}
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
// в failed, тем же infohash могла завладеть другая активная задача — тогда
+49
View File
@@ -578,6 +578,55 @@ func TestRetryRejectsActive(t *testing.T) {
}
}
// TestRetryRejectsLiveErroredTorrent — NIT-12: живой, но сломанный торрент
// (error/missingFiles) при retry не перецепляем и не возвращаем в downloading —
// иначе сверка тут же вернула бы задачу в failed (+дебаунс уведомления), retry
// выглядел бы сломанным. Отклоняем с ErrConflict, состояние не трогаем, повторный
// Add не делаем — пользователю подсказано починить раздачу (recheck) в qBittorrent.
func TestRetryRejectsLiveErroredTorrent(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
for _, state := range []string{"missingFiles", "error"} {
t.Run(state, func(t *testing.T) {
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("qbit_error")
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: state}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
t.Fatalf("err = %v, want ErrConflict", err)
}
if len(qb.added) != 0 {
t.Errorf("сломанный живой торрент не переотдаём: %d Add", len(qb.added))
}
if got := st.downloads["1"].State; got != store.StateFailed {
t.Errorf("state = %s, want failed (не тронуто)", got)
}
})
}
}
// TestRetryReattachesLiveHealthyTorrent — контроль к NIT-12: живой ЗДОРОВЫЙ торрент
// перецепляем без повторного Add (регрессия — errored-ветка не должна задеть
// здоровый путь).
func TestRetryReattachesLiveHealthyTorrent(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateStuck
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "stalledUP"}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.added) != 0 {
t.Errorf("живой здоровый торрент не переотдаём: %d Add", len(qb.added))
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
}
func TestClassify(t *testing.T) {
cases := map[string]class{
"uploading": classReady,
@@ -0,0 +1,42 @@
## Why
Retry задачи в `failed`/`stuck`, чей торрент ЖИВ в qBittorrent, но в состоянии
ошибки (`error`/`missingFiles`), сейчас повторно отдаёт источник. На живом
сломанном торренте это бесполезно: qBittorrent отвергает дубль, задача уходит в
`downloading`, а на ближайшем тике сверки `classErrored` тут же возвращает её в
`failed` (+ дебаунс уведомления). Пользователю retry выглядит сломанным, а
реальное лекарство (перепроверка/`recheck`/восстановление файлов в qBittorrent)
не подсказано (находка ревью NIT-12).
## What Changes
- **Retry сломанного живого торрента отклоняется**, а не переотдаёт источник.
Отказ несёт понятное сообщение: починить раздачу (`recheck`/восстановить
файлы) в qBittorrent и повторить. Состояние задачи не меняется, повторного
`Add` не происходит.
- Повторный `Add` при retry остаётся только для случая, когда раздачи в
qBittorrent НЕТ (перецепка к здоровому живому торренту — без `Add`, как и
раньше).
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `state-reconciliation`: требование «Ручной повтор зависшей/упавшей загрузки из
транспортов» — ветка живого сломанного торрента меняет исход с «повторно отдать
источник» на «отклонить с подсказкой про `recheck`». Прочие ветки retry (нет
раздачи → `Add`; жив и здоров → перецепка без `Add`; сброс базиса `retried_at`)
без изменений.
## Impact
- **Спеки:** дельта `state-reconciliation` (одно MODIFIED-требование).
- **Код:** `internal/worker/worker.go``Retry` (ветка `alive && classErrored`
→ отказ `ErrConflict` с сообщением вместо `reAdd`).
- **Тесты:** `internal/worker/worker_test.go` — отклонение retry на живом
сломанном торренте (`error`/`missingFiles`), контроль перецепки здорового.
- **Миграции БД:** нет.
@@ -0,0 +1,84 @@
## MODIFIED Requirements
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
сбрасываться.
Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от
`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти
процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
Благодаря этому даже живой, но давно добавленный либо давно простаивающий
торрент после retry SHALL получать свежее окно и на ближайшем тике сверки
падать снова SHALL NOT.
Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
к существующему торренту, а не добавлять источник повторно вслепую. Если же
живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry
SHALL отклоняться с понятным пользователю сообщением — починить раздачу
(`recheck`/восстановить файлы) в qBittorrent и повторить. Повторная отдача
источника такой торрент не чинит (qBittorrent отверг бы дубль), а простой
возврат в `downloading` тут же снова упал бы `qbit_error` по сверке (+ дебаунс
уведомления) — retry выглядел бы сломанным. При отказе состояние задачи
(`failed`/`stuck`) система менять SHALL NOT и повторный `Add` выполнять SHALL NOT.
Повторный `Add` при retry система SHALL выполнять, только когда раздачи в
qBittorrent нет, — **по типу источника** (`source_type`), как и добавление
пойманной загрузки (см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent»): magnet/url — ссылкой; torrent — сохранёнными байтами `.torrent`
файлом. Для torrent-источника retry БЕЗ живой раздачи система SHALL добавлять
раздачу байтами и SHALL NOT активировать задачу в `downloading`, не добавив её
(иначе задача повиснет как «нет в qBittorrent»).
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту
#### Scenario: Retry живого, но давно простаивающего торрента не падает снова
- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но
добавлен давно и данные не двигались дольше `stuck_after`
- **WHEN** пользователь нажимает retry
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен
через `retried_at`)
#### Scenario: Retry доступен в Telegram
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
Telegram-боте
- **THEN** задача возвращается в `downloading`
#### Scenario: Retry без живого источника добавляет источник заново
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
torrent сохранёнными байтами файлом
- **AND** задача переходит в `downloading`
#### Scenario: Retry сломанного живого торрента отклоняется
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
состоянии ошибки (`error`/`missingFiles`)
- **WHEN** пользователь инициирует retry
- **THEN** retry отклоняется с сообщением починить раздачу (`recheck`) в
qBittorrent
- **AND** состояние задачи не меняется (остаётся `failed`), повторный `Add` не
выполняется
#### Scenario: Retry torrent-загрузки без живого источника
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
нет, байты `.torrent` сохранены
- **WHEN** пользователь инициирует retry
- **THEN** сохранённые байты добавляются в qBittorrent файлом
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
@@ -0,0 +1,17 @@
## 1. Код
- [x] 1.1 В `Worker.Retry` заменить ветку `alive && classify(state)==classErrored`:
вместо `reAdd=true` — отказ `ErrConflict` с понятным сообщением (починить
раздачу `recheck` в qBittorrent), без изменения состояния и без `Add`
- [x] 1.2 `reAdd` оставить `true` только когда раздачи нет (`!alive`)
## 2. Тесты
- [x] 2.1 `TestRetryRejectsLiveErroredTorrent``error`/`missingFiles`: retry
возвращает `ErrConflict`, состояние `failed` не тронуто, `Add` не вызван
- [x] 2.2 `TestRetryReattachesLiveHealthyTorrent` — контроль: живой здоровый
торрент перецепляется без `Add`
## 3. Спека
- [x] 3.1 MODIFIED-требование в `state-reconciliation`; `openspec validate --strict`
+18 -14
View File
@@ -153,17 +153,20 @@ Retry SHALL переводить задачу обратно в `downloading`,
Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
к существующему торренту, а не добавлять источник повторно вслепую. Если же
живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry
перецепляться к нему SHALL NOT (перецепка к сломанному торренту тут же вернула
бы задачу в `failed` по сверке) и SHALL повторно отдать источник, как при
отсутствии раздачи. Повторный `Add` выполняется, только когда раздачи в
qBittorrent нет ЛИБО она сломана.
SHALL отклоняться с понятным пользователю сообщением — починить раздачу
(`recheck`/восстановить файлы) в qBittorrent и повторить. Повторная отдача
источника такой торрент не чинит (qBittorrent отверг бы дубль), а простой
возврат в `downloading` тут же снова упал бы `qbit_error` по сверке (+ дебаунс
уведомления) — retry выглядел бы сломанным. При отказе состояние задачи
(`failed`/`stuck`) система менять SHALL NOT и повторный `Add` выполнять SHALL NOT.
Повторный `Add` при retry система SHALL выполнять **по типу источника**
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
Повторный `Add` при retry система SHALL выполнять, только когда раздачи в
qBittorrent нет, — **по типу источника** (`source_type`), как и добавление
пойманной загрузки (см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent»): magnet/url — ссылкой; torrent — сохранёнными байтами `.torrent`
файлом. Для torrent-источника retry БЕЗ живой раздачи система SHALL добавлять
раздачу байтами и SHALL NOT активировать задачу в `downloading`, не добавив её
(иначе задача повиснет как «нет в qBittorrent»).
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
@@ -195,14 +198,15 @@ qBittorrent нет ЛИБО она сломана.
torrent сохранёнными байтами файлом
- **AND** задача переходит в `downloading`
#### Scenario: Retry сломанного живого торрента повторно отдаёт источник
#### Scenario: Retry сломанного живого торрента отклоняется
- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
состоянии ошибки (`error`/`missingFiles`)
- **WHEN** пользователь инициирует retry
- **THEN** источник отдаётся заново (перецепка к сломанному торренту не
выполняется)
- **AND** задача переходит в `downloading`
- **THEN** retry отклоняется с сообщением починить раздачу (`recheck`) в
qBittorrent
- **AND** состояние задачи не меняется (остаётся `failed`), повторный `Add` не
выполняется
#### Scenario: Retry torrent-загрузки без живого источника
+2 -2
View File
@@ -447,8 +447,8 @@ table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap
/* key-value список */
.kv{display:grid;grid-template-columns:auto 1fr;gap:7px var(--sp-4);font-size:var(--fs-sm);margin:0}
.kv dt{color:var(--text-faint);white-space:nowrap}
.kv dd{margin:0;color:var(--text);text-align:right;font-variant-numeric:tabular-nums}
.kv dd.mono{font-family:var(--mono)}
.kv dd{margin:0;color:var(--text);text-align:right;font-variant-numeric:tabular-nums;overflow-wrap:anywhere;min-width:0}
.kv dd.mono{font-family:var(--mono);word-break:break-all}
/* плитки статов */
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:var(--sp-3)}
+1 -1
View File
@@ -26,7 +26,7 @@
{{if .ActionError}}<span class="card-meta" style="color:var(--st-err)">{{.ActionError}}</span>{{else}}<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>{{end}}
<div class="card-acts">
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm btn-danger" type="submit">Откатить</button></form>{{end}}
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel" hx-post="/ui/downloads/{{.ID}}/cancel" hx-target="#card-{{.ID}}" hx-swap="outerHTML"><input type="hidden" name="surface" value="list"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
+2 -2
View File
@@ -19,11 +19,11 @@
</div>
<div class="card-acts" style="align-items:center">
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
{{if .Nameable}}<form method="post" action="/ui/downloads/{{.ID}}/refresh-name" hx-post="/ui/downloads/{{.ID}}/refresh-name" hx-target="#download-main" hx-swap="outerHTML"><button class="btn btn-ghost" type="submit" title="Перелить распознанное название в имя загрузки и раздачи">Обновить имя</button></form>{{end}}
{{if .Nameable}}<form method="post" action="/ui/downloads/{{.ID}}/refresh-name" hx-post="/ui/downloads/{{.ID}}/refresh-name" hx-target="#download-main" hx-swap="outerHTML"><button class="btn" type="submit" title="Перелить распознанное название в имя загрузки и раздачи">Обновить имя</button></form>{{end}}
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Повторить</button></form>{{end}}
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel" hx-post="/ui/downloads/{{.ID}}/cancel" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel" hx-post="/ui/downloads/{{.ID}}/cancel" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-danger" type="submit">Отменить</button></form>{{end}}
</div>
</div>