Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0354a8c96b
|
||
|
|
06a0e1ce41
|
||
|
|
bcc7b2d76b
|
||
|
|
b8017d65eb
|
||
|
|
17d9f23630
|
||
|
|
bd7bf60b33
|
@@ -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. Фикс: рунобезопасная обрезка.
|
||||
|
||||
Вердикт: все простые фиксы.
|
||||
@@ -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-фикс либо новая дельта, если хотим зафиксировать семантику весов.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
||||
// не сбой сервера.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 усечённые не отдаёт).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+19
-17
@@ -524,10 +524,8 @@ 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 != "" {
|
||||
byHash[strings.ToLower(h)] = t
|
||||
}
|
||||
for _, h := range torrentIndexHashes(t) {
|
||||
byHash[strings.ToLower(h)] = t
|
||||
}
|
||||
}
|
||||
return byHash
|
||||
@@ -585,12 +583,10 @@ 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 != "" {
|
||||
key := strings.ToLower(h)
|
||||
byHash[key] = t
|
||||
live[key] = l
|
||||
}
|
||||
for _, h := range torrentIndexHashes(t) {
|
||||
key := strings.ToLower(h)
|
||||
byHash[key] = t
|
||||
live[key] = l
|
||||
}
|
||||
}
|
||||
// Снимок зависит только от torrents — свопаем сразу, до store-операций
|
||||
@@ -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 могла завладеть другая активная задача — тогда
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
@@ -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`), контроль перецепки здорового.
|
||||
- **Миграции БД:** нет.
|
||||
+84
@@ -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`
|
||||
@@ -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-загрузки без живого источника
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user