Compare commits
6
Commits
236d886f9e
...
1d6f8f3449
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6f8f3449
|
||
|
|
023fdacda5
|
||
|
|
f75d6b1f91
|
||
|
|
10a6348d39
|
||
|
|
4a58b0bda0
|
||
|
|
85e27b28e2
|
@@ -40,13 +40,11 @@ Tududi (проект `jellybit`) больше **не** держит беклог
|
|||||||
## Низкий
|
## Низкий
|
||||||
|
|
||||||
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
|
- [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи
|
||||||
- [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов
|
|
||||||
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
|
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
|
||||||
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
|
||||||
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
- [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало
|
||||||
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
- [[идея] Многоступенчатая верификация привязки](mnogostupenchataya-verifikaciya.md) — ИДЕЯ (требует проработки)
|
||||||
- [Выбор из нескольких находок метабазы в Telegram](telegram-vybor-nahodok.md) — Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в…
|
- [Согласование канона нумерации серий с провайдером тега](kanon-numeracii-vs-provajder.md) — Косметика/редкость: порядок просмотра ок, но у тайтлов со спорным порядком (Бибоп) Jellyfin подтягивает не те подписи серий, если канон файлов ≠ дефолтный порядок провайдера тега
|
||||||
- [Улучшения UI: показывать матч с записью метабазы в Telegram](telegram-match-metabazy.md) — Название/год/провайдер+id в боте уже выводятся; осталась кликабельная ссылка на запись
|
|
||||||
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
- [Добавление торрентов файлом/ссылкой — «единое окно» (остаток: URL)](dobavlenie-edinoe-okno.md) — magnet и .torrent-файл приняты; остался фетч .torrent по URL (нужен SSRF-гард)
|
||||||
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
- [Раздачи-копии диска (DVD/BluRay: VIDEO_TS/BDMV)](disk-kopii-video-ts-bdmv.md) — Иногда для очень редких фильмов скачивается не один видеофайл, а полная копия диска…
|
||||||
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
- [Проверка свободного места перед copy-fallback](svobodnoe-mesto-copy-fallback.md) — Когда хардлинк невозможен (EXDEV/ENOTSUP/…), layout копирует файл, дублируя место на диске
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Согласование канона нумерации серий с провайдером тега
|
||||||
|
|
||||||
|
**Приоритет:** низкий
|
||||||
|
|
||||||
|
Косметика и редкий случай: порядок просмотра не страдает (файлы уже
|
||||||
|
пронумерованы канонически и лежат по порядку), разъезжаются только подписи серий
|
||||||
|
в Jellyfin — не то название/описание у части эпизодов. Задевает лишь тайтлы с
|
||||||
|
исторически спорным порядком, таких мало.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
У некоторых сериалов есть несколько *легитимных* порядков серий, и разные
|
||||||
|
метабазы придерживаются разных. Каноничный пример — «Ковбой Бибоп»: в титрах и
|
||||||
|
на дисках/IMDb/TVDB порядок «сессий» (Session #1 «Asteroid Blues» … #26), а TMDB
|
||||||
|
по своей политике нумерует по **самой ранней дате эфира**. Часть серий вышла
|
||||||
|
раньше на TV Tokyo вразнобой (2, 3, 7–15, 18) — при сортировке по дате они
|
||||||
|
всплывают вперёд, и диапазон ~1–18 перемешивается. Это не баг одной базы: оба
|
||||||
|
порядка «правильные», просто разные каноны. У TMDB канон отдаётся отдельной
|
||||||
|
episode group (тип DVD/production), у TVDB — отдельными order-типами
|
||||||
|
(Aired/DVD/Absolute).
|
||||||
|
|
||||||
|
## Где это бьёт по jellybit (и где нет)
|
||||||
|
|
||||||
|
jellybit **не** матчит серии по `(season, episode)` между провайдерами —
|
||||||
|
описанного класса бага у нас нет. Номер эпизода рождается из имён файлов через
|
||||||
|
LLM (`recognize.PlanFile.Episode`), проходит без изменений в раскладку и
|
||||||
|
печатается в `SxxEyy`; метабаза даёт лишь `SeasonEpisodeCounts` для гейта
|
||||||
|
полноты пака. То есть мы **доверяем нумерации релиз-группы** и про порядок вообще
|
||||||
|
не знаем.
|
||||||
|
|
||||||
|
Настоящий риск — тихий и уже существует:
|
||||||
|
|
||||||
|
> jellybit пишет `SxxEyy` **и** тег папки `[tmdbid-…]`/`[tvdbid-…]`. Дальше
|
||||||
|
> Jellyfin по этому тегу заново скрейпит серии у *того же* провайдера. Номер в
|
||||||
|
> имени файла и порядок, который ждёт скрейпер, обязаны быть **из одного
|
||||||
|
> канона** — иначе метаданные разъедутся на именно тех сериях.
|
||||||
|
|
||||||
|
Для Бибопа: релиз почти всегда пронумерован канонически (session order). Если
|
||||||
|
матч ушёл на TVDB и написан `[tvdbid-…]` — Jellyfin скрейпит aired order TVDB,
|
||||||
|
для Бибопа = канон, всё сходится. Если матч ушёл на **TMDB** и написан
|
||||||
|
`[tmdbid-…]` — Jellyfin ждёт airing order TMDB (перемешанные 1–18), а файлы
|
||||||
|
канонические → метаданные поедут. Инвариант «канон нумерации файлов ↔ провайдер
|
||||||
|
тега» сейчас нигде не проверяется.
|
||||||
|
|
||||||
|
## Что можно сделать (варианты, не решение)
|
||||||
|
|
||||||
|
- **Минимум (дёшево, ценно):** осознать инвариант и эскалировать в review, когда
|
||||||
|
у распознанного тайтла провайдер матча — из тех, где порядок известно спорный
|
||||||
|
(episode groups у TMDB, absolute order у TVDB), а нумерация файлов может не
|
||||||
|
совпадать с дефолтным скрейп-порядком этого провайдера. Лучше явный вопрос
|
||||||
|
человеку, чем тихий разъезд.
|
||||||
|
- **Предпочтение провайдера тега:** для сериалов с известным расхождением тегать
|
||||||
|
папку провайдером, чей дефолтный порядок совпадает с каноном файлов (обычно
|
||||||
|
TVDB), даже если матч найден в TMDB.
|
||||||
|
- **Максимум:** знать про порядок явно — тянуть episode group (TMDB) / order-тип
|
||||||
|
(TVDB) и сверять нумерацию файлов с выбранным каноном. Требует, чтобы у нас
|
||||||
|
появилось понятие «канон эпизода», которого сейчас в модели нет (эпизодов как
|
||||||
|
сущностей в БД нет, план — JSON-блоб).
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
|
||||||
|
- Тот же класс «у тайтла несколько легитимных порядков», что и
|
||||||
|
[Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) (absolute
|
||||||
|
order через TVDB) — стоит проработать совместно, возможно как одну тему.
|
||||||
|
- [Сложные сериальные раздачи](slozhnye-serialnye-razdachi.md) — соседний пласт
|
||||||
|
крайних случаев раскладки.
|
||||||
|
- Схема «локальная сущность каноническая, provider id — опциональный внешний
|
||||||
|
ключ» уже заложена (draft `logical-title-model.md`, сущность `title` осознанно
|
||||||
|
отвергнута) — эту же логику надо дотянуть до эпизодов/порядка, если пойдём в
|
||||||
|
«максимум».
|
||||||
|
- specs/recognition.md (гейт полноты пака, крайние случаи), specs/jellyfin-layout.md
|
||||||
|
(нумерация серий, тег провайдера), specs/review-ux.md (эскалация в review).
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Download id в Telegram моноширинным (code) для tap-to-copy
|
|
||||||
|
|
||||||
**Приоритет:** низкий
|
|
||||||
|
|
||||||
Сейчас download id выводится в уведомлениях бота обычным текстом с префиксом `#`
|
|
||||||
(`internal/tgbot/bot.go:288`, `render.go:26/50/151`). Хотим слать id как
|
|
||||||
`code`-текст (моноширинный) — в клиентах Telegram по нему работает tap-to-copy,
|
|
||||||
удобно скопировать id для перехода на `/download/{id}` или для диагностики.
|
|
||||||
|
|
||||||
Что учесть:
|
|
||||||
|
|
||||||
- `send()` (`internal/tgbot/bot.go:442`) сейчас не выставляет `ParseMode` — всё
|
|
||||||
уходит plain text. Для `code` нужно включить parse mode. Рекомендуется **HTML**
|
|
||||||
(`<code>%s</code>`) — экранирование проще и локальнее, чем у MarkdownV2.
|
|
||||||
- Включение parse mode затрагивает **все** исходящие сообщения: спецсимволы в
|
|
||||||
display name, путях, названиях сломают разметку, если их не экранировать.
|
|
||||||
Escape-хелпера сейчас нет — нужен, плюс аккуратный проход по всем текстам
|
|
||||||
`render.go`. Это часть общего [[telegram-revyu-uvedomleniy]] — имеет смысл делать
|
|
||||||
вместе.
|
|
||||||
|
|
||||||
Связано: `internal/tgbot` (`render.go`, `bot.go`).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Улучшения UI: показывать матч с записью метабазы в Telegram
|
|
||||||
|
|
||||||
**Приоритет:** низкий
|
|
||||||
|
|
||||||
Web-сторона реализована: страница загрузки /download/{id} и экран ревью показывают, с какой именно записью метабазы (TMDB/TVDB/IMDb) сматчилась загрузка — провайдер, id и ссылку. Осталось довести то же в Telegram: в уведомлениях/подтверждениях показывать запись матча (название, год, провайдер-id, ссылку), чтобы ошибочную привязку было видно и из бота. Полный выбор источника в вебе уже реализован.
|
|
||||||
|
|
||||||
Связано: specs/review-ux.md, specs/recognition.md (матч в базе), specs/architecture.md → «Транспорты».
|
|
||||||
@@ -13,8 +13,9 @@
|
|||||||
запись матча в метабазе, download id, причину `failed`.
|
запись матча в метабазе, download id, причину `failed`.
|
||||||
- **Единый язык:** в текстах бота вперемешку «задача»/«раздача»/«загрузка» —
|
- **Единый язык:** в текстах бота вперемешку «задача»/«раздача»/«загрузка» —
|
||||||
свести к доменному `Download` (см. [[ubiquitous-language-slovar]]).
|
свести к доменному `Download` (см. [[ubiquitous-language-slovar]]).
|
||||||
- **Оформление:** моноширинный download id [[telegram-download-id-code]], возможно
|
- **Оформление:** моноширинный download id — уже сделано (HTML parse mode +
|
||||||
акценты — упирается в parse mode + escape (общая развилка с задачей про code).
|
escape всех текстов, capability `notifications`); осталось при желании добавить
|
||||||
|
акценты поверх включённого parse mode.
|
||||||
- **Не дублировать** уже заведённое: матч метабазы в боте
|
- **Не дублировать** уже заведённое: матч метабазы в боте
|
||||||
[[telegram-match-metabazy]], мульти-бот адресация уведомлений
|
[[telegram-match-metabazy]], мульти-бот адресация уведомлений
|
||||||
[[uvedomleniya-multi-bot]], выбор из нескольких находок [[telegram-vybor-nahodok]].
|
[[uvedomleniya-multi-bot]], выбор из нескольких находок [[telegram-vybor-nahodok]].
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Выбор из нескольких находок метабазы в Telegram
|
|
||||||
|
|
||||||
**Приоритет:** низкий
|
|
||||||
|
|
||||||
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
|
|
||||||
|
|
||||||
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
|
|
||||||
@@ -121,7 +121,8 @@ Telegram = одобрить / подсказать / выбрать кандид
|
|||||||
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
|
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
|
||||||
абсолютной нумерацией → веб-хелпер «absolute → S·E»
|
абсолютной нумерацией → веб-хелпер «absolute → S·E»
|
||||||
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
|
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
|
||||||
- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана.
|
- **Не тот тип (movie↔series)** → «Уточнить» с явным указанием типа
|
||||||
|
перераспознаёт план (отдельного переключателя типа нет — тип read-only).
|
||||||
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
|
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
|
||||||
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
|
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
|
||||||
тип, ввести название/год, разложить файлы руками; в Telegram — сразу
|
тип, ввести название/год, разложить файлы руками; в Telegram — сразу
|
||||||
@@ -152,14 +153,14 @@ Telegram = одобрить / подсказать / выбрать кандид
|
|||||||
## Объём по версиям
|
## Объём по версиям
|
||||||
|
|
||||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||||
заново», переключатель типа, **единый список источников совпадения**
|
заново», **единый список источников совпадения**
|
||||||
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
|
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
|
||||||
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
|
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
|
||||||
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
|
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
|
||||||
источника до применения** (место под режиссёра зарезервировано), пометка
|
источника до применения** (место под режиссёра зарезервировано), пометка
|
||||||
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
|
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
|
||||||
В Telegram — подтверждение с reply-подсказкой
|
В Telegram — подтверждение с reply-подсказкой
|
||||||
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
(«Уточнить»), «Позже»/«Отклонить» и эскалация в веб;
|
||||||
пинги о входе в review и готовности.
|
пинги о входе в review и готовности.
|
||||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||||
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
|
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
view.MatchURL = matchURL(rd, view.MediaType)
|
view.MatchURL = rd.MatchURL()
|
||||||
}
|
}
|
||||||
if rd.Recognition.Confidence.Valid {
|
if rd.Recognition.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func TestAPICommandNotReady(t *testing.T) {
|
|||||||
|
|
||||||
func TestAPICommandInvalidInput(t *testing.T) {
|
func TestAPICommandInvalidInput(t *testing.T) {
|
||||||
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
|
// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
|
||||||
cmd := &fakeCommander{err: fmt.Errorf("set type: invalid type %q: %w", "foo", worker.ErrInvalidInput)}
|
cmd := &fakeCommander{err: fmt.Errorf("choose candidate: invalid id %q: %w", "foo", worker.ErrInvalidInput)}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})
|
||||||
|
|
||||||
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
|
||||||
|
|||||||
@@ -2,36 +2,8 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProviderURL(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
provider string
|
|
||||||
id string
|
|
||||||
mtype string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
|
||||||
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
|
||||||
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
|
||||||
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
|
||||||
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
|
||||||
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
|
||||||
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
if got := providerURL(c.provider, c.id, c.mtype); got != c.want {
|
|
||||||
t.Errorf("providerURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualSource(t *testing.T) {
|
func TestParseManualSource(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -70,28 +42,3 @@ func TestParseManualSource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
|
||||||
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
|
||||||
// матч текстом — сценарий «URL записи неизвестен»).
|
|
||||||
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
|
||||||
rd := &worker.ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
|
||||||
if got := matchURL(rd, "movie"); got != "" {
|
|
||||||
t.Errorf("matchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
|
||||||
// когда его provider+id совпадают с эффективными.
|
|
||||||
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
|
||||||
rd := &worker.ReviewData{
|
|
||||||
Provider: "tmdb", ProviderID: "693134",
|
|
||||||
Candidates: []store.MetadataCandidate{{
|
|
||||||
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
|
||||||
URL: store.NullString("https://custom.example/x"),
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if got := matchURL(rd, "movie"); got != "https://custom.example/x" {
|
|
||||||
t.Errorf("matchURL = %q, want URL выбранного кандидата", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, e
|
|||||||
}
|
}
|
||||||
func (stubReviewer) Apply(context.Context, string) error { return nil }
|
func (stubReviewer) Apply(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
|
func (stubReviewer) Refine(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) SetType(context.Context, string, string) error { return nil }
|
|
||||||
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil }
|
||||||
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
func (stubReviewer) Defer(context.Context, string) error { return nil }
|
||||||
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
func (stubReviewer) Undo(context.Context, string) error { return nil }
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
view.MatchURL = rd.MatchURL()
|
||||||
}
|
}
|
||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
@@ -294,7 +294,7 @@ func looksLikeURL(s string) bool {
|
|||||||
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseProviderURL — обратная к providerURL: URL записи → (provider, id).
|
// parseProviderURL — обратная к worker.ProviderURL: URL записи → (provider, id).
|
||||||
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
||||||
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
||||||
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
||||||
@@ -336,12 +336,12 @@ func leadingDigits(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
||||||
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL).
|
// есть, иначе канонический URL из provider/id (обратный порядок к MatchURL).
|
||||||
func sourceMatchURL(src worker.SourceOption) string {
|
func sourceMatchURL(src worker.SourceOption) string {
|
||||||
if src.URL != "" {
|
if src.URL != "" {
|
||||||
return src.URL
|
return src.URL
|
||||||
}
|
}
|
||||||
return providerURL(src.Provider, src.ProviderID, src.Type)
|
return worker.ProviderURL(src.Provider, src.ProviderID, src.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -503,47 +503,6 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
|||||||
s.render(w, "review_source_swap", view)
|
s.render(w, "review_source_swap", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
|
||||||
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
|
||||||
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
|
||||||
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
|
|
||||||
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
|
|
||||||
func matchURL(rd *worker.ReviewData, mediaType string) string {
|
|
||||||
for _, c := range rd.Candidates {
|
|
||||||
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
|
||||||
c.URL.Valid && c.URL.String != "" {
|
|
||||||
return c.URL.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providerURL(rd.Provider, rd.ProviderID, mediaType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
|
|
||||||
// Пустой id или неизвестный провайдер → пусто.
|
|
||||||
func providerURL(provider, id, mediaType string) string {
|
|
||||||
if id == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch provider {
|
|
||||||
case "tmdb":
|
|
||||||
kind := "movie"
|
|
||||||
if mediaType == "series" {
|
|
||||||
kind = "tv"
|
|
||||||
}
|
|
||||||
return "https://www.themoviedb.org/" + kind + "/" + id
|
|
||||||
case "tvdb":
|
|
||||||
kind := "series"
|
|
||||||
if mediaType == "movie" {
|
|
||||||
kind = "movie"
|
|
||||||
}
|
|
||||||
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
|
||||||
case "imdb":
|
|
||||||
return "https://www.imdb.com/title/" + id
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
||||||
u := "/review/" + id
|
u := "/review/" + id
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
|
|||||||
+44
-11
@@ -43,7 +43,7 @@ type Reviewer interface {
|
|||||||
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
||||||
Apply(ctx context.Context, id string) error
|
Apply(ctx context.Context, id string) error
|
||||||
Refine(ctx context.Context, id string, hint string) error
|
Refine(ctx context.Context, id string, hint string) error
|
||||||
SetType(ctx context.Context, id string, mediaType string) error
|
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||||
Defer(ctx context.Context, id string) error
|
Defer(ctx context.Context, id string) error
|
||||||
Cancel(ctx context.Context, id string) error
|
Cancel(ctx context.Context, id string) error
|
||||||
Retry(ctx context.Context, id string) error
|
Retry(ctx context.Context, id string) error
|
||||||
@@ -161,7 +161,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|||||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+idCode(id)+"…", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,17 +275,17 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
|
|||||||
switch res.State {
|
switch res.State {
|
||||||
case store.StateTargetMissing:
|
case store.StateTargetMissing:
|
||||||
// Источник жив, цель удалена — из target_missing доступна перепривязка.
|
// Источник жив, цель удалена — из target_missing доступна перепривязка.
|
||||||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", res.DownloadID), nil)
|
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", idCode(res.DownloadID)), nil)
|
||||||
case store.StateOrphaned:
|
case store.StateOrphaned:
|
||||||
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
|
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
|
||||||
// добавить заново (тогда приём заведёт свежую загрузку).
|
// добавить заново (тогда приём заведёт свежую загрузку).
|
||||||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", res.DownloadID), nil)
|
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", idCode(res.DownloadID)), nil)
|
||||||
default:
|
default:
|
||||||
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
|
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", idCode(res.DownloadID)), nil)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
|
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", idCode(res.DownloadID)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||||
@@ -354,14 +354,38 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|||||||
case "dismiss_confirm":
|
case "dismiss_confirm":
|
||||||
err = b.reviewer.Dismiss(ctx, id)
|
err = b.reviewer.Dismiss(ctx, id)
|
||||||
note = "Закрываю…"
|
note = "Закрываю…"
|
||||||
case "type":
|
|
||||||
err = b.reviewer.SetType(ctx, id, val)
|
|
||||||
note = "Меняю тип…"
|
|
||||||
case "refine":
|
case "refine":
|
||||||
b.setPending(chatID, id)
|
b.setPending(chatID, id)
|
||||||
b.answer(cq.ID, "Жду подсказку")
|
b.answer(cq.ID, "Жду подсказку")
|
||||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
|
||||||
return
|
return
|
||||||
|
case "sources":
|
||||||
|
// Первый шаг выбора базы: перечитываем задачу ради свежего списка
|
||||||
|
// кандидатов и разворачиваем клавиатуру списка. Домен не трогаем.
|
||||||
|
rd, rerr := b.reviewer.ReviewData(ctx, id)
|
||||||
|
if rerr != nil {
|
||||||
|
b.log.Warn("telegram sources review data failed", "download_id", id, "error", rerr)
|
||||||
|
b.answer(cq.ID, "Ошибка")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.answer(cq.ID, "")
|
||||||
|
b.editMarkup(chatID, msgID, b.candidatesKeyboard(rd))
|
||||||
|
return
|
||||||
|
case "srcback":
|
||||||
|
// Возврат из списка кандидатов к обычной карточке (домен не трогаем).
|
||||||
|
b.answer(cq.ID, "Отменено")
|
||||||
|
b.refreshCard(ctx, chatID, msgID, id)
|
||||||
|
return
|
||||||
|
case "pick":
|
||||||
|
// Выбор кандидата: val — id кандидата из callback_data (недоверенный
|
||||||
|
// вход), валидируем как ULID на границе до доменного вызова.
|
||||||
|
candidateID, perr := ident.Parse(val)
|
||||||
|
if perr != nil {
|
||||||
|
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = b.reviewer.ChooseCandidate(ctx, id, candidateID)
|
||||||
|
note = "Источник выбран"
|
||||||
default:
|
default:
|
||||||
b.answer(cq.ID, "")
|
b.answer(cq.ID, "")
|
||||||
return
|
return
|
||||||
@@ -406,6 +430,9 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id strin
|
|||||||
} else {
|
} else {
|
||||||
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||||
}
|
}
|
||||||
|
// Тот же HTML parse mode, что и в send(): текст карточки содержит <code>-id и
|
||||||
|
// экранированные внешние фрагменты — правка на месте должна их так же трактовать.
|
||||||
|
edit.ParseMode = tgbotapi.ModeHTML
|
||||||
if _, err := b.api.Send(edit); err != nil {
|
if _, err := b.api.Send(edit); err != nil {
|
||||||
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
|
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(err))
|
||||||
}
|
}
|
||||||
@@ -441,6 +468,10 @@ func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.Notify
|
|||||||
|
|
||||||
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
|
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
|
||||||
msg := tgbotapi.NewMessage(chatID, text)
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
// HTML parse mode — ради моноширинного <code> у download id (tap-to-copy).
|
||||||
|
// Разметка становится значимой для ВСЕХ сообщений: внешний текст (имена,
|
||||||
|
// пути, причины, ошибки) обязан быть экранирован через esc() при рендере.
|
||||||
|
msg.ParseMode = tgbotapi.ModeHTML
|
||||||
msg.DisableWebPagePreview = true
|
msg.DisableWebPagePreview = true
|
||||||
if kb != nil {
|
if kb != nil {
|
||||||
msg.ReplyMarkup = *kb
|
msg.ReplyMarkup = *kb
|
||||||
@@ -489,7 +520,9 @@ func (b *Bot) takePending(chatID int64) (string, bool) {
|
|||||||
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
||||||
func opErr(msg string, downloadID string) string {
|
func opErr(msg string, downloadID string) string {
|
||||||
if downloadID != "" {
|
if downloadID != "" {
|
||||||
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
// download_id моноширинным для tap-to-copy; msg — контролируемый литерал
|
||||||
|
// вызывающего (без спецсимволов разметки), потому не экранируется.
|
||||||
|
return fmt.Sprintf("%s (download_id=%s).", msg, idCode(downloadID))
|
||||||
}
|
}
|
||||||
return msg + "."
|
return msg + "."
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-19
@@ -2,6 +2,7 @@ package tgbot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,14 +31,15 @@ type sentMsg struct {
|
|||||||
chatID int64
|
chatID int64
|
||||||
text string
|
text string
|
||||||
hasKB bool
|
hasKB bool
|
||||||
|
parseMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
|
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
|
||||||
switch m := c.(type) {
|
switch m := c.(type) {
|
||||||
case tgbotapi.MessageConfig:
|
case tgbotapi.MessageConfig:
|
||||||
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
||||||
case tgbotapi.EditMessageTextConfig:
|
case tgbotapi.EditMessageTextConfig:
|
||||||
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
|
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
||||||
}
|
}
|
||||||
return tgbotapi.Message{MessageID: 1}, nil
|
return tgbotapi.Message{MessageID: 1}, nil
|
||||||
}
|
}
|
||||||
@@ -65,12 +67,12 @@ type fakeReviewer struct {
|
|||||||
data *worker.ReviewData
|
data *worker.ReviewData
|
||||||
applied []string
|
applied []string
|
||||||
refined map[string]string
|
refined map[string]string
|
||||||
typed map[string]string
|
|
||||||
deferred []string
|
deferred []string
|
||||||
canceled []string
|
canceled []string
|
||||||
retried []string
|
retried []string
|
||||||
deleted []string
|
deleted []string
|
||||||
dismissed []string
|
dismissed []string
|
||||||
|
chosen map[string]string // downloadID → выбранный candidateID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||||
@@ -87,11 +89,11 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
|||||||
f.refined[id] = hint
|
f.refined[id] = hint
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
|
||||||
if f.typed == nil {
|
if f.chosen == nil {
|
||||||
f.typed = map[string]string{}
|
f.chosen = map[string]string{}
|
||||||
}
|
}
|
||||||
f.typed[id] = t
|
f.chosen[id] = candidateID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
||||||
@@ -126,6 +128,9 @@ func reviewData(state store.State) *worker.ReviewData {
|
|||||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||||
Reasons: `["неполный пак"]`,
|
Reasons: `["неполный пак"]`,
|
||||||
},
|
},
|
||||||
|
// Эффективные provider/id (как заполняет effectiveProvider) — их и
|
||||||
|
// показывают уведомления/карточка, консистентно с веб-страницей.
|
||||||
|
Provider: "tvdb", ProviderID: "269613",
|
||||||
Plan: recognize.Plan{
|
Plan: recognize.Plan{
|
||||||
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
||||||
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
||||||
@@ -162,7 +167,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
|||||||
if ing.lastReq.Context != "крутой сериал" {
|
if ing.lastReq.Context != "крутой сериал" {
|
||||||
t.Errorf("context = %q", ing.lastReq.Context)
|
t.Errorf("context = %q", ing.lastReq.Context)
|
||||||
}
|
}
|
||||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
|
||||||
t.Errorf("sent = %+v", api.sent)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,13 +257,8 @@ func TestBot_CallbackApply(t *testing.T) {
|
|||||||
if len(api.edits) != 1 { // карточка обновлена на месте
|
if len(api.edits) != 1 { // карточка обновлена на месте
|
||||||
t.Errorf("edits = %v", api.edits)
|
t.Errorf("edits = %v", api.edits)
|
||||||
}
|
}
|
||||||
}
|
if api.edits[0].parseMode != tgbotapi.ModeHTML {
|
||||||
|
t.Errorf("edit parse mode = %q, want HTML", api.edits[0].parseMode)
|
||||||
func TestBot_CallbackType(t *testing.T) {
|
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
|
||||||
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
|
|
||||||
if rev.typed[tid] != "movie" {
|
|
||||||
t.Errorf("typed = %v", rev.typed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ func TestBot_NotifyReview(t *testing.T) {
|
|||||||
if len(api.sent) != 2 { // обоим доверенным
|
if len(api.sent) != 2 { // обоим доверенным
|
||||||
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
||||||
}
|
}
|
||||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
|
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+idCode(tid)) {
|
||||||
t.Errorf("card text = %q", api.sent[0].text)
|
t.Errorf("card text = %q", api.sent[0].text)
|
||||||
}
|
}
|
||||||
if !api.sent[0].hasKB {
|
if !api.sent[0].hasKB {
|
||||||
@@ -317,7 +317,7 @@ func TestBot_NotifyFailed(t *testing.T) {
|
|||||||
// В ошибке — и заголовок (display_name), и #id для поиска по логам.
|
// В ошибке — и заголовок (display_name), и #id для поиска по логам.
|
||||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
|
||||||
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
|
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
|
||||||
!strings.Contains(api.sent[0].text, "#"+tid) {
|
!strings.Contains(api.sent[0].text, "#"+idCode(tid)) {
|
||||||
t.Errorf("sent = %+v", api.sent)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
if !api.sent[0].hasKB { // кнопка повтора
|
if !api.sent[0].hasKB { // кнопка повтора
|
||||||
@@ -325,6 +325,111 @@ func TestBot_NotifyFailed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML parse mode включён у всех исходящих: внешний текст (название, источник,
|
||||||
|
// причины) должен экранироваться, а download id — уходить моноширинным <code>.
|
||||||
|
// Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку.
|
||||||
|
func TestBot_NotifyEscapesExternalText(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Download.DisplayName = "" // чтобы карточка показала Plan.Title через guessLine
|
||||||
|
rd.Plan.Title = "Tom & Jerry <b>x</b>"
|
||||||
|
// Источник длиннее лимита shorten(80) со спецсимволом ровно на границе:
|
||||||
|
// проверяем, что esc идёт ПОСЛЕ усечения (иначе сущность разрубится).
|
||||||
|
rd.Download.Context = strings.Repeat("a", 79) + "&" + strings.Repeat("b", 5)
|
||||||
|
rev.data = rd
|
||||||
|
|
||||||
|
b.Notify(context.Background(), tid, worker.EventReview)
|
||||||
|
|
||||||
|
if len(api.sent) != 1 {
|
||||||
|
t.Fatalf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
msg := api.sent[0]
|
||||||
|
if msg.parseMode != tgbotapi.ModeHTML {
|
||||||
|
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
||||||
|
}
|
||||||
|
// download id — моноширинным (tap-to-copy).
|
||||||
|
if !strings.Contains(msg.text, idCode(tid)) {
|
||||||
|
t.Errorf("id не в <code>: %q", msg.text)
|
||||||
|
}
|
||||||
|
// Спецсимволы названия экранированы, сырая разметка не просочилась.
|
||||||
|
if !strings.Contains(msg.text, "Tom & Jerry <b>x</b>") {
|
||||||
|
t.Errorf("название не экранировано: %q", msg.text)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg.text, "<b>") {
|
||||||
|
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
||||||
|
}
|
||||||
|
// Усечённый источник: сущность на границе цела (esc после shorten даёт
|
||||||
|
// «&…», а не разрубленное «&am…»).
|
||||||
|
if !strings.Contains(msg.text, "&…") {
|
||||||
|
t.Errorf("источник обрезан посреди сущности: %q", msg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed-путь (renderFailed): спецсимволы в названии и тексте ошибки
|
||||||
|
// экранируются, #id уходит моноширинным. Закрывает opErr/failed-ветку формата.
|
||||||
|
func TestBot_NotifyFailedEscapesExternalText(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateFailed)
|
||||||
|
rd.Download.DisplayName = "A & B <x>"
|
||||||
|
rd.Download.ErrorMsg = store.NullString("path <bad> & stuff")
|
||||||
|
rev.data = rd
|
||||||
|
|
||||||
|
b.Notify(context.Background(), tid, worker.EventFailed)
|
||||||
|
|
||||||
|
if len(api.sent) != 1 {
|
||||||
|
t.Fatalf("sent = %+v", api.sent)
|
||||||
|
}
|
||||||
|
msg := api.sent[0]
|
||||||
|
if msg.parseMode != tgbotapi.ModeHTML {
|
||||||
|
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.text, "«A & B <x>»") {
|
||||||
|
t.Errorf("название failed не экранировано: %q", msg.text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.text, "path <bad> & stuff") {
|
||||||
|
t.Errorf("текст ошибки не экранирован: %q", msg.text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.text, "#"+idCode(tid)) {
|
||||||
|
t.Errorf("id не в <code>: %q", msg.text)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg.text, "<x>") {
|
||||||
|
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка ревью: спецсимволы во внешнем пути раскладки и в причинах
|
||||||
|
// распознавания экранируются (esc после tailPath/join), сырая разметка не
|
||||||
|
// просачивается. Закрывает сценарий «Внешний путь и причины экранируются».
|
||||||
|
func TestReviewCard_EscapesPathAndReasons(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Recognition.Reasons = `["Tom & Jerry <b>x</b>"]`
|
||||||
|
rd.Preview[0].Dst = "/srv/media/series/A & B <x>/file <y>.mkv"
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "Tom & Jerry <b>x</b>") {
|
||||||
|
t.Errorf("причины не экранированы:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "A & B <x>") || !strings.Contains(text, "file <y>.mkv") {
|
||||||
|
t.Errorf("путь раскладки не экранирован:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "<b>") || strings.Contains(text, "<x>") {
|
||||||
|
t.Errorf("сырая разметка просочилась:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// opErr: при наличии download id он уходит моноширинным (tap-to-copy) под
|
||||||
|
// ключом download_id=; без id — дружелюбный текст без ключа. Закрывает сценарий
|
||||||
|
// «Id в сообщении об ошибке копируется».
|
||||||
|
func TestOpErr_IDMonospace(t *testing.T) {
|
||||||
|
if got := opErr("Не удалось выполнить действие", tid); !strings.Contains(got, "download_id="+idCode(tid)) {
|
||||||
|
t.Errorf("id не моноширинным под ключом download_id: %q", got)
|
||||||
|
}
|
||||||
|
if got := opErr("Готово", ""); strings.Contains(got, "download_id") {
|
||||||
|
t.Errorf("без id ключа download_id быть не должно: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBot_CallbackRetry(t *testing.T) {
|
func TestBot_CallbackRetry(t *testing.T) {
|
||||||
b, _, _, rev := newTestBot(t, []int64{7})
|
b, _, _, rev := newTestBot(t, []int64{7})
|
||||||
rev.data = reviewData(store.StateFailed)
|
rev.data = reviewData(store.StateFailed)
|
||||||
@@ -336,8 +441,8 @@ func TestBot_CallbackRetry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCallback(t *testing.T) {
|
func TestParseCallback(t *testing.T) {
|
||||||
a, id, v := parseCallback("type:" + tid + ":series")
|
a, id, v := parseCallback("pick:" + tid + ":" + tid)
|
||||||
if a != "type" || id != tid || v != "series" {
|
if a != "pick" || id != tid || v != tid {
|
||||||
t.Errorf("got %q %q %q", a, id, v)
|
t.Errorf("got %q %q %q", a, id, v)
|
||||||
}
|
}
|
||||||
a, id, v = parseCallback("apply:" + tid)
|
a, id, v = parseCallback("apply:" + tid)
|
||||||
@@ -350,6 +455,126 @@ func TestParseCallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
|
||||||
|
// валидируется как ULID).
|
||||||
|
const (
|
||||||
|
candID1 = "01arz3ndektsv4rrffq69g5fb1"
|
||||||
|
candID2 = "01arz3ndektsv4rrffq69g5fb2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findButton ищет inline-кнопку по callback data во всей клавиатуре.
|
||||||
|
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
|
||||||
|
for _, row := range kb.InlineKeyboard {
|
||||||
|
for _, btn := range row {
|
||||||
|
if btn.CallbackData != nil && *btn.CallbackData == data {
|
||||||
|
return btn, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tgbotapi.InlineKeyboardButton{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
|
||||||
|
func withCandidates() *worker.ReviewData {
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Candidates = []store.MetadataCandidate{
|
||||||
|
{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
||||||
|
{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
|
||||||
|
}
|
||||||
|
return rd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
|
||||||
|
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
|
||||||
|
kb := b.reviewKeyboard(withCandidates())
|
||||||
|
if _, ok := findButton(kb, "sources:"+tid); !ok {
|
||||||
|
t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
|
||||||
|
}
|
||||||
|
|
||||||
|
if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
|
||||||
|
_, ok := findButton(kbNone, "sources:"+tid)
|
||||||
|
return ok
|
||||||
|
}() {
|
||||||
|
t.Error("без кандидатов кнопки выбора базы быть не должно")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
|
||||||
|
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
|
||||||
|
func TestBot_CandidatesKeyboard(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := withCandidates()
|
||||||
|
// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
|
||||||
|
rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})
|
||||||
|
|
||||||
|
kb := b.candidatesKeyboard(rd)
|
||||||
|
if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
|
||||||
|
t.Error("нет кнопки первого кандидата")
|
||||||
|
}
|
||||||
|
chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("нет кнопки выбранного кандидата")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(chosen.Text, "✓ ") {
|
||||||
|
t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
|
||||||
|
}
|
||||||
|
if _, ok := findButton(kb, "srcback:"+tid); !ok {
|
||||||
|
t.Error("нет кнопки возврата")
|
||||||
|
}
|
||||||
|
// Дубль не породил третью кнопку выбора.
|
||||||
|
if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
|
||||||
|
t.Error("дубль provider:id должен быть схлопнут")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
|
||||||
|
func TestBot_CallbackPick(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
rev.data = withCandidates()
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))
|
||||||
|
|
||||||
|
if rev.chosen[tid] != candID1 {
|
||||||
|
t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
|
||||||
|
}
|
||||||
|
if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
|
||||||
|
t.Errorf("edits = %v, want 1", api.edits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
|
||||||
|
func TestBot_CallbackPickInvalidID(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))
|
||||||
|
|
||||||
|
if len(rev.chosen) != 0 {
|
||||||
|
t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
|
||||||
|
}
|
||||||
|
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
||||||
|
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
|
||||||
|
func TestBot_CallbackSourcesBack(t *testing.T) {
|
||||||
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
rev.data = withCandidates()
|
||||||
|
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
|
||||||
|
if len(rev.chosen) != 0 {
|
||||||
|
t.Error("разворачивание списка не должно трогать домен")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
|
||||||
|
if len(rev.chosen) != 0 {
|
||||||
|
t.Error("возврат не должен трогать домен")
|
||||||
|
}
|
||||||
|
if len(api.edits) != 1 { // srcback обновляет карточку на месте
|
||||||
|
t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
||||||
func TestBot_CallbackStaleButton(t *testing.T) {
|
func TestBot_CallbackStaleButton(t *testing.T) {
|
||||||
b, api, _, rev := newTestBot(t, []int64{7})
|
b, api, _, rev := newTestBot(t, []int64{7})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func TestBot_IngestFromDocument(t *testing.T) {
|
|||||||
if ing.lastReq.Context != "Дюна 2" {
|
if ing.lastReq.Context != "Дюна 2" {
|
||||||
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
|
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
|
||||||
}
|
}
|
||||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
|
||||||
t.Errorf("sent = %+v", api.sent)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-43
@@ -11,6 +11,30 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// esc экранирует внешний/недоверенный текст для HTML parse mode Telegram (его
|
||||||
|
// включает send(); см. bot.go). Применяем к КАЖДОМУ фрагменту, пришедшему извне
|
||||||
|
// (display name, названия, пути, причины, provider, текст/код ошибки, источник):
|
||||||
|
// иначе `<`/`>`/`&` в них сломают разметку или инъектируют её (инвариант «выход
|
||||||
|
// LLM недоверенный»). ВАЖНО: экранируем ПОСЛЕДНИМ шагом — над уже усечённым
|
||||||
|
// текстом (после shorten/tailPath/firstLine), чтобы обрез не разрубил сущность
|
||||||
|
// `<` на битую разметку.
|
||||||
|
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }
|
||||||
|
|
||||||
|
// escHref экранирует URL для вставки в значение атрибута `href`. Контекст
|
||||||
|
// атрибута строже текстового: esc (EscapeText) закрывает `<`/`>`/`&`, но НЕ
|
||||||
|
// трогает `"`, а недоверенный id уходит в URL сырым (см. worker.ProviderURL) —
|
||||||
|
// кавычка в id разорвала бы атрибут и Telegram отклонил бы сообщение (parse
|
||||||
|
// error → уведомление не доставится). Поэтому поверх esc заменяем `"` на
|
||||||
|
// `"`; порядок безопасен: esc уже перевёл `&` в `&`, повторно `&` в
|
||||||
|
// `"` не удвоится.
|
||||||
|
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, """) }
|
||||||
|
|
||||||
|
// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
|
||||||
|
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
|
||||||
|
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
|
||||||
|
// чистый id.
|
||||||
|
func idCode(id string) string { return "<code>" + esc(id) + "</code>" }
|
||||||
|
|
||||||
// renderCard строит текст и клавиатуру карточки по состоянию задачи.
|
// renderCard строит текст и клавиатуру карточки по состоянию задачи.
|
||||||
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
|
||||||
id := rd.Download.ID
|
id := rd.Download.ID
|
||||||
@@ -20,15 +44,16 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
|||||||
case store.StateReview, store.StateDeferred:
|
case store.StateReview, store.StateDeferred:
|
||||||
return b.reviewCard(rd)
|
return b.reviewCard(rd)
|
||||||
case store.StateRecognizing:
|
case store.StateRecognizing:
|
||||||
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
|
return "⏳ Распознаю #" + idCode(id) + "…", b.webOnly(id)
|
||||||
case store.StateLinking:
|
case store.StateLinking:
|
||||||
return "⏳ Раскладываю #" + id + "…", nil
|
return "⏳ Раскладываю #" + idCode(id) + "…", nil
|
||||||
case store.StateDone:
|
case store.StateDone:
|
||||||
return b.renderDone(rd), b.deletableKeyboard(id)
|
return b.renderDone(rd), b.deletableKeyboard(id)
|
||||||
default:
|
default:
|
||||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
|
||||||
|
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
|
||||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||||
text += "\n" + msg
|
text += "\n" + esc(msg)
|
||||||
}
|
}
|
||||||
switch state {
|
switch state {
|
||||||
case store.StateFailed, store.StateStuck:
|
case store.StateFailed, store.StateStuck:
|
||||||
@@ -46,19 +71,24 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
|||||||
id := rd.Download.ID
|
id := rd.Download.ID
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
|
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", idCode(id))
|
||||||
if src := contextOrSource(rd); src != "" {
|
if src := contextOrSource(rd); src != "" {
|
||||||
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
fmt.Fprintf(&sb, "Источник: %s\n", esc(shorten(src, 80)))
|
||||||
}
|
}
|
||||||
|
// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
|
||||||
|
// внутри) — повторно не экранируем.
|
||||||
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
|
||||||
if base := baseLine(rd.Recognition); base != "" {
|
// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
|
||||||
|
if base := baseLine(rd); base != "" {
|
||||||
fmt.Fprintf(&sb, "База: %s\n", base)
|
fmt.Fprintf(&sb, "База: %s\n", base)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("База: нет матча\n")
|
||||||
}
|
}
|
||||||
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
|
||||||
fmt.Fprintf(&sb, "Причины: %s\n", strings.Join(reasons, " · "))
|
fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
|
||||||
}
|
}
|
||||||
if n := len(rd.Preview); n > 0 {
|
if n := len(rd.Preview); n > 0 {
|
||||||
fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst))
|
fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
|
||||||
@@ -72,7 +102,10 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
|||||||
if len(rd.Preview) > 0 {
|
if len(rd.Preview) > 0 {
|
||||||
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
|
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
|
||||||
}
|
}
|
||||||
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
|
// Быстрый выбор базы из готового списка кандидатов (веб — точный ввод id/URL).
|
||||||
|
if n := len(dedupCandidates(rd.Candidates)); n > 0 {
|
||||||
|
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
|
||||||
|
}
|
||||||
|
|
||||||
row2 := tgbotapi.NewInlineKeyboardRow(
|
row2 := tgbotapi.NewInlineKeyboardRow(
|
||||||
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
|
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
|
||||||
@@ -89,6 +122,68 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
|||||||
return &kb
|
return &kb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candidatesKeyboard — второй шаг выбора базы: список кандидатов метабазы (по
|
||||||
|
// кнопке на кандидата) + возврат к карточке. Текст кнопки Telegram не парсится как
|
||||||
|
// HTML, поэтому название кандидата в подписи не экранируем (инъекция разметки в
|
||||||
|
// кнопке невозможна); длинную подпись усекаем — косметика. Выбранный (chosen)
|
||||||
|
// помечаем галочкой. callback выбора — `pick:<id>:<candidateID>` (id кандидата
|
||||||
|
// валидируется как ULID на границе в handleCallback).
|
||||||
|
func (b *Bot) candidatesKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||||
|
sid := rd.Download.ID
|
||||||
|
var rows [][]tgbotapi.InlineKeyboardButton
|
||||||
|
for _, c := range dedupCandidates(rd.Candidates) {
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData(candidateLabel(c), "pick:"+sid+":"+c.ID),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||||
|
tgbotapi.NewInlineKeyboardButtonData("← Назад", "srcback:"+sid),
|
||||||
|
))
|
||||||
|
kb := tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||||
|
return &kb
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupCandidates отсеивает повторы по provider:provider_id (стабильный порядок
|
||||||
|
// исходного списка) — как единый список источников в вебе. Потолок — сами
|
||||||
|
// кандидаты (recognize.maxCandidates=8), отдельно не режем. Если среди дублей
|
||||||
|
// одного ключа выбранный (Chosen) идёт не первым, он вытесняет ранее оставленный
|
||||||
|
// невыбранный — чтобы галочка и id кнопки указывали на выбранного.
|
||||||
|
func dedupCandidates(cands []store.MetadataCandidate) []store.MetadataCandidate {
|
||||||
|
idx := map[string]int{}
|
||||||
|
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||||
|
for _, c := range cands {
|
||||||
|
key := c.Provider + ":" + c.ProviderID
|
||||||
|
if i, ok := idx[key]; ok {
|
||||||
|
if c.Chosen && !out[i].Chosen {
|
||||||
|
out[i] = c
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx[key] = len(out)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateLabel — подпись кнопки кандидата: `provider · Название (год)`, у
|
||||||
|
// выбранного — префикс «✓ ». Обычный текст (не HTML), усекаем для читаемости.
|
||||||
|
func candidateLabel(c store.MetadataCandidate) string {
|
||||||
|
label := c.Provider
|
||||||
|
if c.Title.Valid && c.Title.String != "" {
|
||||||
|
label += " · " + c.Title.String
|
||||||
|
} else if c.ProviderID != "" {
|
||||||
|
label += " " + c.ProviderID
|
||||||
|
}
|
||||||
|
if c.Year.Valid && c.Year.Int64 != 0 {
|
||||||
|
label += fmt.Sprintf(" (%d)", c.Year.Int64)
|
||||||
|
}
|
||||||
|
label = shorten(label, 48)
|
||||||
|
if c.Chosen {
|
||||||
|
label = "✓ " + label
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
|
// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
|
||||||
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
|
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
|
||||||
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
|
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
|
||||||
@@ -99,32 +194,44 @@ func displayTitle(rd *worker.ReviewData) string {
|
|||||||
return rd.Plan.Title
|
return rd.Plan.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDone — короткое сообщение о готовности.
|
// titleLabel — экранированная метка задачи для уведомлений: «display name» (в
|
||||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
// кавычках) либо моноширинный #id как фолбек. Готова к вставке в HTML-сообщение:
|
||||||
title := displayTitle(rd)
|
// внешний title экранируется, id оборачивается в <code> (tap-to-copy).
|
||||||
if title == "" {
|
func titleLabel(rd *worker.ReviewData) string {
|
||||||
title = "#" + rd.Download.ID
|
if t := displayTitle(rd); t != "" {
|
||||||
|
return "«" + esc(t) + "»"
|
||||||
}
|
}
|
||||||
|
return "#" + idCode(rd.Download.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
|
||||||
|
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
|
||||||
|
// строку опускаем — в готовности «нет матча» лишний шум.
|
||||||
|
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||||
|
label := titleLabel(rd)
|
||||||
|
var sb strings.Builder
|
||||||
n := len(rd.Preview)
|
n := len(rd.Preview)
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return fmt.Sprintf("✅ Готово: «%s» разложен.", title)
|
fmt.Fprintf(&sb, "✅ Готово: %s разложен.", label)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&sb, "✅ Готово: %s — разложено файлов: %d.", label, n)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
|
if base := baseLine(rd); base != "" {
|
||||||
|
fmt.Fprintf(&sb, "\nБаза: %s", base)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||||
title := displayTitle(rd)
|
label := titleLabel(rd)
|
||||||
if title == "" {
|
|
||||||
title = "#" + rd.Download.ID
|
|
||||||
}
|
|
||||||
switch event {
|
switch event {
|
||||||
case worker.EventTargetMissing:
|
case worker.EventTargetMissing:
|
||||||
return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title)
|
return fmt.Sprintf("⚠️ %s: файлы удалены из библиотеки, источник на месте — можно привязать заново.", label)
|
||||||
case worker.EventOrphaned:
|
case worker.EventOrphaned:
|
||||||
return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title)
|
return fmt.Sprintf("⚠️ %s: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", label)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title)
|
return fmt.Sprintf("⚠️ %s: рассинхрон состояния.", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,20 +245,20 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
|||||||
}
|
}
|
||||||
// Заголовок (display_name) для читаемости + #id для поиска по логам.
|
// Заголовок (display_name) для читаемости + #id для поиска по логам.
|
||||||
if title := displayTitle(rd); title != "" {
|
if title := displayTitle(rd); title != "" {
|
||||||
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", title, id, verb)
|
fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", esc(title), idCode(id), verb)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
|
fmt.Fprintf(&sb, "❌ Задача #%s %s", idCode(id), verb)
|
||||||
}
|
}
|
||||||
if code := rd.Download.ErrorCode.String; code != "" {
|
if code := rd.Download.ErrorCode.String; code != "" {
|
||||||
fmt.Fprintf(&sb, " (%s)", code)
|
fmt.Fprintf(&sb, " (%s)", esc(code))
|
||||||
}
|
}
|
||||||
sb.WriteString(".")
|
sb.WriteString(".")
|
||||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(msg)
|
sb.WriteString(esc(msg))
|
||||||
}
|
}
|
||||||
if src := contextOrSource(rd); src != "" {
|
if src := contextOrSource(rd); src != "" {
|
||||||
fmt.Fprintf(&sb, "\nИсточник: %s", shorten(src, 80))
|
fmt.Fprintf(&sb, "\nИсточник: %s", esc(shorten(src, 80)))
|
||||||
}
|
}
|
||||||
return sb.String(), b.retryKeyboard(id)
|
return sb.String(), b.retryKeyboard(id)
|
||||||
}
|
}
|
||||||
@@ -236,21 +343,35 @@ func guessLine(rd *worker.ReviewData) string {
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = "не распознано"
|
title = "не распознано"
|
||||||
}
|
}
|
||||||
s := fmt.Sprintf("%s %s «%s»", emoji, kind, title)
|
// Возвращаем уже экранированный текст (title — внешний/распознанный):
|
||||||
|
// вызывающий вставляет как есть, без повторного esc.
|
||||||
|
s := fmt.Sprintf("%s %s «%s»", emoji, kind, esc(title))
|
||||||
if rd.Plan.Year != 0 {
|
if rd.Plan.Year != 0 {
|
||||||
s += fmt.Sprintf(" (%d)", rd.Plan.Year)
|
s += fmt.Sprintf(" (%d)", rd.Plan.Year)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseLine(rec *store.Recognition) string {
|
// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
|
||||||
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
|
// с учётом ручных правок — как на веб-странице загрузки), при возможности
|
||||||
return "нет матча"
|
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
|
||||||
|
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
|
||||||
|
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
|
||||||
|
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
|
||||||
|
// индикатор «нет матча».
|
||||||
|
func baseLine(rd *worker.ReviewData) string {
|
||||||
|
prov := rd.Provider
|
||||||
|
if prov == "" || prov == "none" {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
|
label := esc(prov)
|
||||||
return rec.Provider.String + " " + rec.ProviderID.String
|
if rd.ProviderID != "" {
|
||||||
|
label += " " + esc(rd.ProviderID)
|
||||||
}
|
}
|
||||||
return rec.Provider.String
|
if url := rd.MatchURL(); url != "" {
|
||||||
|
return fmt.Sprintf(`<a href="%s">%s ↗</a>`, escHref(url), label)
|
||||||
|
}
|
||||||
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextOrSource(rd *worker.ReviewData) string {
|
func contextOrSource(rd *worker.ReviewData) string {
|
||||||
@@ -260,13 +381,6 @@ func contextOrSource(rd *worker.ReviewData) string {
|
|||||||
return rd.Download.SourceRef
|
return rd.Download.SourceRef
|
||||||
}
|
}
|
||||||
|
|
||||||
func oppositeType(t string) string {
|
|
||||||
if t == "series" {
|
|
||||||
return "movie"
|
|
||||||
}
|
|
||||||
return "series"
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstLine(s string) string {
|
func firstLine(s string) string {
|
||||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
return s[:i]
|
return s[:i]
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tgbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Карточка ревью показывает запись матча ссылкой на страницу записи (тот же
|
||||||
|
// билдер URL, что и веб): provider+id внутри <a href>, стрелка ↗, экранировано.
|
||||||
|
func TestReviewCard_ShowsMatchLink(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview) // tvdb/269613, сериал
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
want := `<a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`
|
||||||
|
if !strings.Contains(text, "База: "+want) {
|
||||||
|
t.Errorf("нет ссылки матча в карточке ревью:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись матча берёт эффективные provider/id (с учётом ручных правок), а не
|
||||||
|
// сырое распознавание — консистентно с веб-страницей загрузки.
|
||||||
|
func TestReviewCard_UsesEffectiveProvider(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
// Сырое распознавание — одно, эффективный выбор (ручная правка) — другое.
|
||||||
|
rd.Recognition.Provider = store.NullString("tvdb")
|
||||||
|
rd.Recognition.ProviderID = store.NullString("269613")
|
||||||
|
rd.Provider, rd.ProviderID = "imdb", "tt0111161"
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "imdb tt0111161") {
|
||||||
|
t.Errorf("должен показываться эффективный провайдер:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "tvdb") || strings.Contains(text, "269613") {
|
||||||
|
t.Errorf("сырое распознавание не должно просачиваться:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Матч без строящегося URL (неизвестный провайдер) — текстом provider id, без
|
||||||
|
// ссылки.
|
||||||
|
func TestReviewCard_MatchWithoutURLAsText(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "kinopoisk", "42"
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "База: kinopoisk 42") {
|
||||||
|
t.Errorf("матч без URL должен быть текстом:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "<a href") {
|
||||||
|
t.Errorf("для несобираемого URL ссылки быть не должно:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет матча: карточка ревью показывает индикатор «нет матча».
|
||||||
|
func TestReviewCard_NoMatchIndicator(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "none", ""
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "База: нет матча") {
|
||||||
|
t.Errorf("нет индикатора «нет матча»:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уведомление о готовности содержит запись матча (база + ссылка) — чтобы
|
||||||
|
// ошибочную привязку было видно после раскладки.
|
||||||
|
func TestRenderDone_ShowsMatch(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateDone)
|
||||||
|
text := b.renderDone(rd)
|
||||||
|
|
||||||
|
if !strings.Contains(text, "Готово") {
|
||||||
|
t.Fatalf("не сообщение о готовности:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, `База: <a href="https://www.thetvdb.com/dereferrer/series/269613">tvdb 269613 ↗</a>`) {
|
||||||
|
t.Errorf("нет строки матча в готовности:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без матча уведомление о готовности строку матча опускает (в финальном пинге
|
||||||
|
// «нет матча» — шум).
|
||||||
|
func TestRenderDone_OmitsBaseWithoutMatch(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateDone)
|
||||||
|
rd.Provider, rd.ProviderID = "none", ""
|
||||||
|
text := b.renderDone(rd)
|
||||||
|
|
||||||
|
if strings.Contains(text, "База") {
|
||||||
|
t.Errorf("без матча строки «База» в готовности быть не должно:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кавычка в id (а значит в URL) экранируется в значении href ("), атрибут
|
||||||
|
// остаётся целым — иначе Telegram отклонил бы сообщение (parse error).
|
||||||
|
func TestReviewCard_QuoteInURLEscaped(t *testing.T) {
|
||||||
|
b, _, _, _ := newTestBot(t, []int64{7})
|
||||||
|
rd := reviewData(store.StateReview)
|
||||||
|
rd.Provider, rd.ProviderID = "imdb", `tt1"onmouseover=x`
|
||||||
|
text, _ := b.renderCard(rd)
|
||||||
|
|
||||||
|
if strings.Contains(text, `="x`) || !strings.Contains(text, """) {
|
||||||
|
t.Errorf("кавычка в href не экранирована:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
id string
|
||||||
|
mtype string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
||||||
|
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
||||||
|
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
||||||
|
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
||||||
|
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
||||||
|
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
||||||
|
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := ProviderURL(c.provider, c.id, c.mtype); got != c.want {
|
||||||
|
t.Errorf("ProviderURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||||
|
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||||
|
// матч текстом — сценарий «URL записи неизвестен»).
|
||||||
|
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
||||||
|
rd := &ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
||||||
|
if got := rd.MatchURL(); got != "" {
|
||||||
|
t.Errorf("MatchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLCanonicalFromEffective — без кандидатов ссылка строится по
|
||||||
|
// эффективным provider/id и типу медиа плана.
|
||||||
|
func TestMatchURLCanonicalFromEffective(t *testing.T) {
|
||||||
|
rd := &ReviewData{
|
||||||
|
Provider: "tmdb", ProviderID: "60622",
|
||||||
|
Plan: recognize.Plan{Type: recognize.MediaSeries},
|
||||||
|
}
|
||||||
|
if got := rd.MatchURL(); got != "https://www.themoviedb.org/tv/60622" {
|
||||||
|
t.Errorf("MatchURL = %q, want канонический URL по типу плана", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
||||||
|
// когда его provider+id совпадают с эффективными.
|
||||||
|
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
||||||
|
rd := &ReviewData{
|
||||||
|
Provider: "tmdb", ProviderID: "693134",
|
||||||
|
Candidates: []store.MetadataCandidate{{
|
||||||
|
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
||||||
|
URL: store.NullString("https://custom.example/x"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if got := rd.MatchURL(); got != "https://custom.example/x" {
|
||||||
|
t.Errorf("MatchURL = %q, want URL выбранного кандидата", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-41
@@ -23,7 +23,6 @@ import (
|
|||||||
|
|
||||||
// Поля override.
|
// Поля override.
|
||||||
const (
|
const (
|
||||||
ovrMediaType = "media_type"
|
|
||||||
ovrIgnoredFiles = "ignored_files"
|
ovrIgnoredFiles = "ignored_files"
|
||||||
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
||||||
ovrProviderID = "provider_id" // id в выбранной базе
|
ovrProviderID = "provider_id" // id в выбранной базе
|
||||||
@@ -475,38 +474,6 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
|
||||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
|
||||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
|
|
||||||
defer func() { w.logCmd(ctx, "set_type", id, err) }()
|
|
||||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
|
||||||
return fmt.Errorf("set type: invalid type %q: %w", mediaType, ErrInvalidInput)
|
|
||||||
}
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
d, err := w.requireReviewable(ctx, id, "set type")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := w.ensureSourceReady(ctx, d, "set type"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
|
||||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
|
||||||
return fmt.Errorf("set type: %w", err)
|
|
||||||
}
|
|
||||||
label := "фильм"
|
|
||||||
if mediaType == string(recognize.MediaSeries) {
|
|
||||||
label = "сериал"
|
|
||||||
}
|
|
||||||
if err := w.store.AddHint(ctx, id, "Тип точно: "+label+"."); err != nil {
|
|
||||||
return fmt.Errorf("set type: %w", err)
|
|
||||||
}
|
|
||||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
||||||
// превью пересчитается с учётом правки.
|
// превью пересчитается с учётом правки.
|
||||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
|
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
|
||||||
@@ -957,6 +924,49 @@ type ReviewData struct {
|
|||||||
Overrides map[string]string
|
Overrides map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||||
|
// билдер для всех транспортов (веб и Telegram строят ссылку одинаково). Приоритет
|
||||||
|
// — URL выбранного кандидата, но только если его provider+id совпадают с
|
||||||
|
// эффективными (человек мог выбрать кандидата, затем вручную переопределить id —
|
||||||
|
// тогда кандидат указывает на другую запись). Иначе строим канонический URL по
|
||||||
|
// эффективным provider/id и типу медиа плана; если не удаётся — пусто (транспорт
|
||||||
|
// покажет матч текстом).
|
||||||
|
func (rd *ReviewData) MatchURL() string {
|
||||||
|
for _, c := range rd.Candidates {
|
||||||
|
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
||||||
|
c.URL.Valid && c.URL.String != "" {
|
||||||
|
return c.URL.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderURL строит канонический URL записи метабазы с учётом типа медиа.
|
||||||
|
// Пустой id или неизвестный провайдер → пусто.
|
||||||
|
func ProviderURL(provider, id, mediaType string) string {
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case "tmdb":
|
||||||
|
kind := "movie"
|
||||||
|
if mediaType == "series" {
|
||||||
|
kind = "tv"
|
||||||
|
}
|
||||||
|
return "https://www.themoviedb.org/" + kind + "/" + id
|
||||||
|
case "tvdb":
|
||||||
|
kind := "series"
|
||||||
|
if mediaType == "movie" {
|
||||||
|
kind = "movie"
|
||||||
|
}
|
||||||
|
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
||||||
|
case "imdb":
|
||||||
|
return "https://www.imdb.com/title/" + id
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SourceKind — вид источника в едином списке ревью.
|
// SourceKind — вид источника в едином списке ревью.
|
||||||
type SourceKind string
|
type SourceKind string
|
||||||
|
|
||||||
@@ -1105,8 +1115,7 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mergeSourceOverrides накладывает пины источника (provider/id/title/year/
|
// mergeSourceOverrides накладывает пины источника (provider/id/title/year/
|
||||||
// director) на неисточниковые overrides (media_type, ignored_files, force_review,
|
// director) на неисточниковые overrides (ignored_files, force_review, ...).
|
||||||
// ...).
|
|
||||||
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
|
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
|
||||||
m := make(map[string]string, len(base)+len(pins))
|
m := make(map[string]string, len(base)+len(pins))
|
||||||
for k, v := range base {
|
for k, v := range base {
|
||||||
@@ -1264,13 +1273,10 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
|
|||||||
|
|
||||||
// --- Хелперы преобразования ---
|
// --- Хелперы преобразования ---
|
||||||
|
|
||||||
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
|
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
|
||||||
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
|
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
|
||||||
// ignore (их раскладка пропустит).
|
// раскладка пропустит).
|
||||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
||||||
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
|
||||||
plan.Type = recognize.MediaType(mt)
|
|
||||||
}
|
|
||||||
if t := overrides[ovrTitle]; t != "" {
|
if t := overrides[ovrTitle]; t != "" {
|
||||||
plan.Title = t
|
plan.Title = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ func TestReviewCommands_RejectNotReadySource(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }},
|
{"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }},
|
||||||
{"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }},
|
{"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }},
|
||||||
{"set type", store.StateReview, func(w *Worker) error { return w.SetType(context.Background(), "1", "series") }},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -949,28 +948,6 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetType(t *testing.T) {
|
|
||||||
st := newMemStore()
|
|
||||||
d := completedDownload("1")
|
|
||||||
d.State = store.StateReview
|
|
||||||
st.put(d)
|
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
|
|
||||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
|
||||||
|
|
||||||
if err := w.SetType(context.Background(), "1", "series"); err != nil {
|
|
||||||
t.Fatalf("SetType: %v", err)
|
|
||||||
}
|
|
||||||
if st.overrides["1"][ovrMediaType] != "series" {
|
|
||||||
t.Errorf("override = %v", st.overrides["1"])
|
|
||||||
}
|
|
||||||
if st.downloads["1"].State != store.StateRecognizing {
|
|
||||||
t.Errorf("state = %q, want recognizing", st.downloads["1"].State)
|
|
||||||
}
|
|
||||||
if err := w.SetType(context.Background(), "1", "cartoon"); err == nil {
|
|
||||||
t.Error("invalid type must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIgnoreFile(t *testing.T) {
|
func TestIgnoreFile(t *testing.T) {
|
||||||
st := newMemStore()
|
st := newMemStore()
|
||||||
d := completedDownload("1")
|
d := completedDownload("1")
|
||||||
@@ -1583,17 +1560,18 @@ func TestReviewData(t *testing.T) {
|
|||||||
func TestApplyOverrides(t *testing.T) {
|
func TestApplyOverrides(t *testing.T) {
|
||||||
plan := recognize.Plan{
|
plan := recognize.Plan{
|
||||||
Type: recognize.MediaMovie,
|
Type: recognize.MediaMovie,
|
||||||
|
Title: "Сырое название",
|
||||||
Files: []recognize.PlanFile{
|
Files: []recognize.PlanFile{
|
||||||
{Src: "a.mkv", Role: recognize.RoleMain},
|
{Src: "a.mkv", Role: recognize.RoleMain},
|
||||||
{Src: "b.mkv", Role: recognize.RoleEpisode},
|
{Src: "b.mkv", Role: recognize.RoleEpisode},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
out := applyOverrides(plan, map[string]string{
|
out := applyOverrides(plan, map[string]string{
|
||||||
ovrMediaType: "series",
|
ovrTitle: "Каноническое название",
|
||||||
ovrIgnoredFiles: `["a.mkv"]`,
|
ovrIgnoredFiles: `["a.mkv"]`,
|
||||||
})
|
})
|
||||||
if out.Type != recognize.MediaSeries {
|
if out.Title != "Каноническое название" {
|
||||||
t.Errorf("type = %q, want series", out.Type)
|
t.Errorf("title = %q, want override", out.Title)
|
||||||
}
|
}
|
||||||
if out.Files[0].Role != "ignore" {
|
if out.Files[0].Role != "ignore" {
|
||||||
t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role)
|
t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// Ф1 ведёт задачу downloading → completed, плюс stuck/failed по таймаутам и
|
// Ф1 ведёт задачу downloading → completed, плюс stuck/failed по таймаутам и
|
||||||
// ошибкам qBittorrent. Ф3 продолжает: completed → recognizing (вызов
|
// ошибкам qBittorrent. Ф3 продолжает: completed → recognizing (вызов
|
||||||
// recognize) → review; команды ревью (apply/refine/reject/defer/undo,
|
// recognize) → review; команды ревью (apply/refine/reject/defer/undo,
|
||||||
// переключение типа, пометка «игнор») раскладывают файлы хардлинками через
|
// пометка «игнор») раскладывают файлы хардлинками через
|
||||||
// layout. Распознавание зовётся в поллинг-цикле, команды — из транспортов;
|
// layout. Распознавание зовётся в поллинг-цикле, команды — из транспортов;
|
||||||
// всё под per-download блокировкой w.mu.
|
// всё под per-download блокировкой w.mu.
|
||||||
package worker
|
package worker
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Убрать явный переключатель типа movie↔series из ревью
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
В ревью тип контента (movie/series) не имеет явного переключателя в вебе, но
|
||||||
|
Telegram-бот давал кнопку «📺↔🎬 Тип» (доменная команда `SetType`: жёсткий
|
||||||
|
override `media_type` + хинт + перераспознавание). Это создавало расхождение
|
||||||
|
поверхностей и внутреннюю противоречивость спеки `review`: требование «Команды
|
||||||
|
ревью и их эффекты» запрещает переключатель типа на «экране ревью» (веб), но там
|
||||||
|
же упоминает «фиксацию типа» как команду, а «Разделение труда транспортов» даёт
|
||||||
|
боту «переключить тип».
|
||||||
|
|
||||||
|
Решение (принято пользователем): смена типа — редкий случай, для него достаточно
|
||||||
|
повторного распознавания с явным указанием типа через «Уточнить» (мягкая
|
||||||
|
подсказка). Явный переключатель типа не нужен **ни на одной поверхности**.
|
||||||
|
|
||||||
|
## Что меняется
|
||||||
|
|
||||||
|
- **Код:** снять кнопку «Тип» и её callback из Telegram-бота, убрать `SetType`
|
||||||
|
из интерфейса `Reviewer` бота и доменную команду `worker.SetType`, а также
|
||||||
|
ставший мёртвым override-плумбинг `media_type` (константа `ovrMediaType`,
|
||||||
|
ветка в `applyOverrides`, хелпер `oppositeType`). Путь коррекции типа —
|
||||||
|
существующий `Refine` («Уточнить»).
|
||||||
|
- **Спека `review`:** переформулировать запрет переключателя типа на **все**
|
||||||
|
поверхности (веб + Telegram); убрать «фиксацию типа» из списка команд,
|
||||||
|
которым нужен источник, и из списка быстрых действий Telegram; заменить в
|
||||||
|
сценарии «Override переживает перераспознавание» иллюстрацию с типа на
|
||||||
|
закрепление источника (тип больше не override).
|
||||||
|
|
||||||
|
## Влияние
|
||||||
|
|
||||||
|
- Capability: **review** (MODIFIED, 3 требования). Косвенно — тексты/клавиатура
|
||||||
|
`notifications` (реализация бота), без изменения его требований.
|
||||||
|
- Обратная совместимость: устаревшая callback-кнопка `type:` в старых
|
||||||
|
сообщениях перестанет действовать (как любая устаревшая кнопка — «Кнопка
|
||||||
|
устарела»). Данные не мигрируют: override `media_type` в проде не
|
||||||
|
использовался в вебе; если где-то записан — просто перестаёт применяться
|
||||||
|
(перераспознавание вернёт распознанный тип), поведение безопасно.
|
||||||
|
- Миграций БД нет.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Команды ревью и их эффекты
|
||||||
|
|
||||||
|
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
|
||||||
|
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
|
||||||
|
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
|
||||||
|
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
|
||||||
|
ссылки → `reverted`) и **Привязать заново** (из
|
||||||
|
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
||||||
|
подтверждением). Никакая поверхность ревью — ни веб-UI, ни Telegram — MUST NOT
|
||||||
|
содержать команду переключения типа movie↔series: тип показывается read-only, а
|
||||||
|
его корректировка выполняется мягкой подсказкой через **Уточнить**
|
||||||
|
(перераспознавание, где пользователь явно указывает тип). Команды из любого
|
||||||
|
транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется
|
||||||
|
последняя валидная команда.
|
||||||
|
|
||||||
|
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
|
||||||
|
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
|
||||||
|
отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ
|
||||||
|
добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю
|
||||||
|
сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` —
|
||||||
|
единственное состояние без раздачи среди не-терминальных: откладывать в нём
|
||||||
|
нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в
|
||||||
|
лимбо — `processCatched` листает только `catched` и больше её не подхватит, а
|
||||||
|
последующие команды через отсутствие источника выводят необратимый `deleted`.
|
||||||
|
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
|
||||||
|
|
||||||
|
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
||||||
|
заново**, **Привязать заново**), SHALL синхронно (без дебаунса) проверять перед
|
||||||
|
действием, что источник не только присутствует в qBittorrent, но и **готов к
|
||||||
|
раскладке** — раздача в готовом классе состояния
|
||||||
|
(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5),
|
||||||
|
т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное
|
||||||
|
или переходное `moving`/`checking` состояние), команда SHALL отказывать с
|
||||||
|
конфликтом и причиной «торрент ещё качается», НЕ создавая хардлинки и НЕ меняя
|
||||||
|
состояние загрузки (её нахождение в `review`/`deferred`/… легитимно, приводить
|
||||||
|
к реальности нечего). Отсутствие источника в qBittorrent SHALL по-прежнему
|
||||||
|
приводить состояние к реальности (`orphaned`/`deleted`) и отказывать. Так
|
||||||
|
недокачанная задача не может пройти через перераспознавание в авто-раскладку
|
||||||
|
или ручное применение и захардлинкать неполные файлы, обойдя финальность
|
||||||
|
состояния `completed`.
|
||||||
|
|
||||||
|
#### Scenario: Применение создаёт раскладку
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с эффективным планом
|
||||||
|
- **WHEN** пользователь выбирает «Применить»
|
||||||
|
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
|
||||||
|
|
||||||
|
#### Scenario: Отклонить и привязать заново
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`
|
||||||
|
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
|
||||||
|
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
||||||
|
подтверждением (авто-раскладка не делается)
|
||||||
|
|
||||||
|
#### Scenario: Тип не переключается командой ни в одном транспорте
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с распознанным типом
|
||||||
|
- **WHEN** пользователь открывает ревью в вебе или в Telegram
|
||||||
|
- **THEN** отдельной команды/кнопки переключения movie↔series нет ни на одной
|
||||||
|
поверхности
|
||||||
|
- **AND** тип показан read-only; для смены типа пользователь уточняет
|
||||||
|
распознавание («Уточнить», явно указав тип)
|
||||||
|
|
||||||
|
#### Scenario: Позже паркует задачу из ревью
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть)
|
||||||
|
- **WHEN** пользователь выбирает «Позже»
|
||||||
|
- **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по
|
||||||
|
любому последующему действию
|
||||||
|
|
||||||
|
#### Scenario: Позже отклоняется для пре-источникового catched
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent)
|
||||||
|
- **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на
|
||||||
|
`/ui/downloads/{id}/defer`)
|
||||||
|
- **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить
|
||||||
|
можно только после добавления торрента
|
||||||
|
- **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через
|
||||||
|
`processCatched`
|
||||||
|
|
||||||
|
#### Scenario: Недокачанный источник отклоняет перераспознавание
|
||||||
|
|
||||||
|
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
|
||||||
|
качается (`downloading`, файлы не докачаны)
|
||||||
|
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
|
||||||
|
заново»)
|
||||||
|
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
|
||||||
|
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
|
||||||
|
не запускается
|
||||||
|
|
||||||
|
#### Scenario: Недокачанный источник отклоняет ручное применение
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, чья раздача в qBittorrent ещё качается
|
||||||
|
- **WHEN** пользователь выбирает «Применить»
|
||||||
|
- **THEN** команда отклоняется с конфликтом «торрент ещё качается», хардлинки
|
||||||
|
на неполные файлы не создаются, состояние загрузки не меняется
|
||||||
|
|
||||||
|
### Requirement: Подсказка мягкая, override жёсткий
|
||||||
|
|
||||||
|
Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при
|
||||||
|
перераспознавании. Ручная правка поля SHALL быть жёстким **override**: система
|
||||||
|
берёт значение как есть и «пиннит» его; перераспознавание НЕ SHALL затирать уже
|
||||||
|
поправленное поле. Накопленные подсказки и правки SHALL переживать
|
||||||
|
перераспознавание и накладываться на новый план.
|
||||||
|
|
||||||
|
#### Scenario: Override переживает перераспознавание
|
||||||
|
|
||||||
|
- **GIVEN** пользователь закрепил источник (кандидат метабазы) как эффективный
|
||||||
|
матч
|
||||||
|
- **WHEN** запускается перераспознавание по новой подсказке
|
||||||
|
- **THEN** в новом эффективном плане закреплённые название/год/провайдер
|
||||||
|
остаются
|
||||||
|
|
||||||
|
### Requirement: Разделение труда транспортов в ревью
|
||||||
|
|
||||||
|
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||||
|
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
|
||||||
|
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
|
||||||
|
(одобрить, подсказать, **быстрый выбор источника из готового списка кандидатов
|
||||||
|
метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
||||||
|
страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, маппинг
|
||||||
|
файлов), SHALL делаться в вебе.
|
||||||
|
|
||||||
|
#### Scenario: Эскалация из Telegram в веб
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
|
||||||
|
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||||
|
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||||
|
|
||||||
|
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
|
||||||
|
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
|
||||||
|
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
|
||||||
|
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Задачи
|
||||||
|
|
||||||
|
## 1. Спека review (дельта)
|
||||||
|
|
||||||
|
- [x] MODIFIED «Команды ревью и их эффекты»: запрет переключателя типа — на все
|
||||||
|
поверхности; убрать «фиксацию типа» из команд, которым нужен источник;
|
||||||
|
обобщить сценарий «Тип не переключается кнопкой»; убрать «/фиксацию типа»
|
||||||
|
из сценария недокачанного источника.
|
||||||
|
- [x] MODIFIED «Разделение труда транспортов в ревью»: убрать «переключить тип»
|
||||||
|
из быстрых действий Telegram.
|
||||||
|
- [x] MODIFIED «Подсказка мягкая, override жёсткий»: сценарий «Override
|
||||||
|
переживает перераспознавание» — иллюстрация закреплением источника вместо
|
||||||
|
типа.
|
||||||
|
|
||||||
|
## 2. Код
|
||||||
|
|
||||||
|
- [x] `internal/tgbot/render.go`: убрать кнопку «📺↔🎬 Тип» из `reviewKeyboard`
|
||||||
|
и неиспользуемый хелпер `oppositeType`.
|
||||||
|
- [x] `internal/tgbot/bot.go`: убрать ветку callback `type:` и `SetType` из
|
||||||
|
интерфейса `Reviewer`.
|
||||||
|
- [x] `internal/worker/review.go`: удалить `SetType`, константу `ovrMediaType`,
|
||||||
|
ветку `media_type` в `applyOverrides`; поправить комментарии,
|
||||||
|
упоминающие переключение/фиксацию типа.
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
- [x] `internal/tgbot/bot_test.go`: убрать `SetType` из `fakeReviewer`, тест
|
||||||
|
callback `type:`; в `TestParseCallback` заменить action `type` на живой.
|
||||||
|
- [x] `internal/worker/review_test.go`: удалить `TestSetType` и ветку «set type»
|
||||||
|
из табличных тестов.
|
||||||
|
|
||||||
|
## 4. Проверка
|
||||||
|
|
||||||
|
- [x] `task test`, `task lint` — зелёные.
|
||||||
|
- [x] `openspec validate --strict` — valid.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Download id в уведомлениях бота сейчас уходит обычным текстом с префиксом `#`
|
||||||
|
(`internal/tgbot/bot.go`, `render.go`). В клиентах Telegram по моноширинному
|
||||||
|
`<code>`-тексту работает tap-to-copy — удобно скопировать id для перехода на
|
||||||
|
`/download/{id}` или для диагностики по логам. Обычный текст так скопировать
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
Чтобы получить `<code>`, нужно включить у исходящих сообщений parse mode. Это
|
||||||
|
делает разметку значимой для **всех** текстов бота: спецсимволы (`<`, `>`, `&`)
|
||||||
|
в display name, путях, распознанных названиях, причинах и тексте ошибок сломают
|
||||||
|
сообщение или будут истолкованы как разметка. Escape-хелпера сейчас нет — его
|
||||||
|
надо ввести и аккуратно применить ко всем внешним фрагментам.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Download id выводится моноширинным** (`<code>{id}</code>`) во всех
|
||||||
|
уведомлениях бота — tap-to-copy. Визуальный префикс (`#` / `download_id=`)
|
||||||
|
остаётся вне `<code>`, чтобы копировался чистый id.
|
||||||
|
- **Включается HTML parse mode** у всех исходящих сообщений — как у `send()`, так
|
||||||
|
и у edit-пути обновления карточки (`refreshCard`).
|
||||||
|
- **Вводится escape-хелпер**; все внешние/недоверенные фрагменты (display name,
|
||||||
|
распознанное название, источник/контекст, путь плана, причины распознавания,
|
||||||
|
provider, `error_code`/`error_msg`) экранируются перед вставкой в размеченное
|
||||||
|
сообщение. Инвариант «выход LLM недоверенный» распространяется на разметку.
|
||||||
|
- Секреты по-прежнему не попадают в сообщения и логи (без изменений).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `notifications`: добавляется требование к **формату** уведомлений — download id
|
||||||
|
моноширинным (tap-to-copy) и инвариант безопасного экранирования внешнего
|
||||||
|
текста при включённом форматировании. Условия и события доставки уведомлений
|
||||||
|
(падение, review, готовность, рассинхрон) без изменений.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Спеки:** дельта `notifications` (два ADDED-требования: формат id и
|
||||||
|
экранирование).
|
||||||
|
- **Код:** `internal/tgbot/bot.go` (`send` — ParseMode HTML; `refreshCard` —
|
||||||
|
ParseMode HTML на edit; композиция id как `<code>`), `internal/tgbot/render.go`
|
||||||
|
(escape-хелпер + экранирование всех внешних фрагментов, id в `<code>`).
|
||||||
|
- **Тесты:** `internal/tgbot/bot_test.go` — обновить ожидания текста (id теперь в
|
||||||
|
`<code>`), добавить проверку экранирования спецсимволов во внешнем фрагменте.
|
||||||
|
- **Миграции БД:** нет.
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
|
||||||
|
|
||||||
|
Уведомления, содержащие download id, система SHALL отображать id моноширинным
|
||||||
|
блоком (в Telegram — `<code>`), чтобы в клиенте работало tap-to-copy: id можно
|
||||||
|
скопировать одним касанием для перехода на `/download/{id}` или диагностики по
|
||||||
|
логам. Визуальный префикс (`#` / `download_id=`) SHALL оставаться вне
|
||||||
|
моноширинного блока, чтобы копировался чистый id без лишних символов.
|
||||||
|
|
||||||
|
#### Scenario: Id карточки review копируется одним касанием
|
||||||
|
|
||||||
|
- **GIVEN** уведомление о входе загрузки в `review` с download id
|
||||||
|
- **WHEN** бот рендерит сообщение
|
||||||
|
- **THEN** download id выводится моноширинным блоком (tap-to-copy), а префикс `#`
|
||||||
|
остаётся обычным текстом вне блока
|
||||||
|
|
||||||
|
#### Scenario: Id в сообщении об ошибке копируется
|
||||||
|
|
||||||
|
- **GIVEN** сообщение об отказе операции с `download_id`
|
||||||
|
- **WHEN** бот рендерит сообщение
|
||||||
|
- **THEN** значение download id выводится моноширинным блоком для копирования
|
||||||
|
|
||||||
|
### Requirement: Экранирование внешнего текста при форматированных уведомлениях
|
||||||
|
|
||||||
|
При включённом форматировании исходящих сообщений (parse mode) система MUST
|
||||||
|
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
|
||||||
|
сообщение: display name, распознанное название, источник/контекст, целевой путь,
|
||||||
|
причины распознавания, provider, код и текст ошибки. Это защищает от того, что
|
||||||
|
спецсимволы разметки сломают сообщение или что разметка будет инъектирована из
|
||||||
|
недоверенного источника (инвариант «выход LLM недоверенный»). Секреты
|
||||||
|
(токены/ключи/пароли) MUST NOT попадать в текст уведомлений и логи.
|
||||||
|
|
||||||
|
#### Scenario: Спецсимволы в названии не ломают разметку
|
||||||
|
|
||||||
|
- **GIVEN** уведомление, где display name или распознанное название содержит
|
||||||
|
символы разметки (`<`, `>`, `&`)
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
|
||||||
|
из недоверенного текста не интерпретируется
|
||||||
|
|
||||||
|
#### Scenario: Внешний путь и причины экранируются
|
||||||
|
|
||||||
|
- **GIVEN** уведомление с целевым путём плана и причинами распознавания
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** символы разметки в пути и причинах экранируются перед вставкой
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## 1. Код
|
||||||
|
|
||||||
|
- [x] 1.1 `internal/tgbot/render.go`: добавить хелпер `esc` (экранирование
|
||||||
|
внешнего текста для HTML parse mode Telegram) и `idCode` (обёртка download id в
|
||||||
|
`<code>` для tap-to-copy)
|
||||||
|
- [x] 1.2 `internal/tgbot/bot.go`: в `send()` выставить `ParseMode = HTML`; в
|
||||||
|
`refreshCard()` выставить `ParseMode = HTML` на edit-конфиге (оба варианта:
|
||||||
|
с клавиатурой и без)
|
||||||
|
- [x] 1.3 Заменить вывод download id на `idCode(id)` во всех сообщениях
|
||||||
|
(`bot.go`: pending/принято/дубль/refine/`opErr`; `render.go`: карточки, дефолтная
|
||||||
|
ветка `renderCard`, распознаю/раскладываю, done/failed, **`renderDesync`**)
|
||||||
|
- [x] 1.4 Экранировать все внешние фрагменты в `render.go` через `esc`: display
|
||||||
|
name / распознанное название, источник/контекст, путь плана, причины,
|
||||||
|
provider **и `provider_id`**, `error_code`, `error_msg`. Не забыть
|
||||||
|
**`renderDesync`** (внешний `displayTitle` уходит в `send` с HTML).
|
||||||
|
**Порядок:** `esc` применяем ПОСЛЕДНИМ шагом — над уже усечённым текстом
|
||||||
|
(после `shorten`/`tailPath`/`firstLine`), иначе обрез посреди сущности
|
||||||
|
`<` даст битую разметку → Telegram 400 → сообщение не доставится.
|
||||||
|
|
||||||
|
## 2. Тесты
|
||||||
|
|
||||||
|
- [x] 2.1 `internal/tgbot/bot_test.go`: обновить ожидания текста под `<code>`-id
|
||||||
|
- [x] 2.2 Добавить тест: внешний фрагмент со спецсимволами (`<`/`>`/`&`) в
|
||||||
|
уведомлении экранируется (не ломает разметку) — покрыть desync/failed-путь и
|
||||||
|
кейс усечения длинного значения со спецсимволом у границы `shorten`
|
||||||
|
|
||||||
|
## 3. Спека
|
||||||
|
|
||||||
|
- [x] 3.1 Дельта `notifications` (два ADDED-требования); `openspec validate
|
||||||
|
--strict telegram-download-id-code`
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Билдер URL записи метабазы сейчас живёт в транспорте `internal/httpapi`
|
||||||
|
(`providerURL` — канонический URL по provider/id/type; `matchURL` — выбор ссылки
|
||||||
|
матча: URL выбранного кандидата, если его provider+id совпадают с эффективными,
|
||||||
|
иначе канонический). Telegram-транспорт (`internal/tgbot`) не может переиспользовать
|
||||||
|
эти функции: транспорт не должен зависеть от другого транспорта, да и незачем
|
||||||
|
дублировать логику. Оба транспорта уже зависят от ядра `internal/worker` и его
|
||||||
|
`ReviewData`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
### Билдер URL — в ядро worker
|
||||||
|
|
||||||
|
Переносим в `internal/worker`:
|
||||||
|
|
||||||
|
- `func ProviderURL(provider, id, mediaType string) string` — экспортируемая
|
||||||
|
чистая функция (канонический URL; пустой id или неизвестный провайдер → пусто).
|
||||||
|
- `func (rd *ReviewData) MatchURL() string` — метод: приоритет URL выбранного
|
||||||
|
кандидата (при совпадении provider+id с эффективными), иначе
|
||||||
|
`ProviderURL(rd.Provider, rd.ProviderID, string(rd.Plan.Type))`. Тип медиа для
|
||||||
|
URL всегда `rd.Plan.Type` (так и звали оба вызова в httpapi), поэтому метод
|
||||||
|
берёт его сам — вызывающему не нужно передавать.
|
||||||
|
|
||||||
|
httpapi делегирует: `view.MatchURL = rd.MatchURL()`; `sourceMatchURL` зовёт
|
||||||
|
`worker.ProviderURL(...)`. Обратный разбор `parseProviderURL` (URL → provider/id)
|
||||||
|
и парсинг ручного ввода остаются в httpapi — это транспортный ввод, не общий
|
||||||
|
билдер. Тесты `providerURL`/`matchURL` переезжают в `internal/worker`.
|
||||||
|
|
||||||
|
### Что показываем в боте
|
||||||
|
|
||||||
|
`baseLine` меняем: принимает `*worker.ReviewData` (а не сырой `*store.Recognition`),
|
||||||
|
использует **эффективные** `rd.Provider`/`rd.ProviderID` (как веб — с учётом
|
||||||
|
ручных правок) и `rd.MatchURL()`:
|
||||||
|
|
||||||
|
- есть URL → `<a href="URL">provider id ↗</a>` (provider, id экранированы через
|
||||||
|
`esc`; URL — через `escHref`, см. «Безопасность»);
|
||||||
|
- нет URL → `provider id` текстом (как раньше);
|
||||||
|
- матча нет (`""`/`none`) → возвращает пусто (вызывающий решает, показывать ли
|
||||||
|
индикатор).
|
||||||
|
|
||||||
|
Строку матча показываем в двух местах:
|
||||||
|
|
||||||
|
- **карточка ревью** (`reviewCard`) — точка подтверждения, где привязку ещё можно
|
||||||
|
поправить; строка «База: …» уже была, добавляем в неё ссылку и переводим на
|
||||||
|
эффективный провайдер. Когда матча нет — сохраняем текущее поведение: «База:
|
||||||
|
нет матча» (в ревью полезно видеть, что база не выбрана).
|
||||||
|
- **уведомление о готовности** (`renderDone`) — добавляем строку «База: …**только
|
||||||
|
при наличии матча**, чтобы ошибочную привязку было видно и в финальном пинге
|
||||||
|
(файлы уже разложены, но расхождение заметно сразу). Без матча строку опускаем
|
||||||
|
— в готовности «нет матча» лишний шум.
|
||||||
|
|
||||||
|
Асимметрия «нет матча» между поверхностями осознанная: индикатор в ревью помогает
|
||||||
|
(можно добавить базу), в финальном пинге — нет. `baseLine` поэтому отдаёт пусто на
|
||||||
|
«нет матча», а текст «нет матча» подставляет `reviewCard` (единственная
|
||||||
|
поверхность, где он нужен).
|
||||||
|
|
||||||
|
Прочие уведомления (падение/рассинхрон) матч не показывают: там нет
|
||||||
|
подтверждённого результата раскладки, релевантна причина сбоя, а не запись базы.
|
||||||
|
|
||||||
|
### Безопасность
|
||||||
|
|
||||||
|
`provider`, `id`, `URL` — недоверенные (метабаза/LLM/ручной ввод). `provider` и id
|
||||||
|
экранируются через `esc` (текстовый контекст). Для **URL контекст другой —
|
||||||
|
значение атрибута `href`**, а `esc` (`tgbotapi.EscapeText(ModeHTML)`) заменяет
|
||||||
|
только `<`, `>`, `&` и **не трогает `"`**. Между тем `ProviderURL` подставляет id
|
||||||
|
в URL сырым (`"…/title/" + id`), а id недоверенный: id с `"` разорвал бы атрибут
|
||||||
|
`href` и Telegram отклонил бы сообщение (parse error) → уведомление о матче тихо
|
||||||
|
не доставилось бы. Поэтому URL экранируем хелпером `escHref`, который поверх `esc`
|
||||||
|
дополнительно заменяет `"` на `"` (порядок безопасен: `esc` уже перевёл `&` в
|
||||||
|
`&`, так что `&` в `"` не удвоится). Ссылка рисуется только при непустом
|
||||||
|
URL из `MatchURL()`. Миграции не нужны — данные матча уже в БД (`recognition`,
|
||||||
|
`metadata_candidate`).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Веб уже показывает, с какой именно записью метабазы сматчилась загрузка:
|
||||||
|
страница `/download/{id}` и экран ревью выводят provider, id и — если URL
|
||||||
|
строится — ссылку на страницу записи (`internal/httpapi`: `matchURL`/
|
||||||
|
`providerURL`, шаблон `download_main.html`). В Telegram матч показан беднее:
|
||||||
|
карточка ревью (`internal/tgbot/render.go`, `baseLine`) выводит только
|
||||||
|
`provider id` из **сырого** распознавания и **без ссылки**, а уведомление о
|
||||||
|
готовности матч не показывает вовсе. Из-за этого ошибочную привязку (не тот
|
||||||
|
фильм/сезон) из бота не видно — приходится открывать веб.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Карточка подтверждения (review)** в боте показывает матч со **ссылкой** на
|
||||||
|
запись метабазы (когда URL строится) — как веб: `provider id ↗`. Без URL —
|
||||||
|
тем же текстом, что и раньше.
|
||||||
|
- **Уведомление о готовности** (`renderDone`) показывает строку матча (provider,
|
||||||
|
id, ссылка) — чтобы ошибочную привязку было видно и в финальном пинге.
|
||||||
|
- **Provider/id берутся эффективные** (`ReviewData.Provider`/`ProviderID`, с
|
||||||
|
учётом ручных правок), консистентно с веб-страницей и экраном ревью, а не из
|
||||||
|
сырого распознавания.
|
||||||
|
- **Единый билдер URL:** канонический `providerURL` и выбор ссылки матча
|
||||||
|
`matchURL` переезжают из `internal/httpapi` в ядро `internal/worker`
|
||||||
|
(`worker.ProviderURL` + метод `(*ReviewData).MatchURL()`), чтобы оба
|
||||||
|
транспорта (веб и Telegram) строили ссылку одинаково. httpapi делегирует туда.
|
||||||
|
- **Экранирование:** provider, id и URL — недоверенные, экранируются перед
|
||||||
|
вставкой в HTML-сообщение (инвариант «выход LLM недоверенный»); ссылка
|
||||||
|
рисуется только при непустом URL. URL — в контексте атрибута `href`: помимо
|
||||||
|
`<`/`>`/`&` экранируется и кавычка (иначе изготовленный id разорвёт атрибут и
|
||||||
|
Telegram отклонит сообщение).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `notifications`: добавляется требование к **содержанию** уведомлений/
|
||||||
|
подтверждений — показ записи матча метабазы (provider, id, ссылка) в карточке
|
||||||
|
ревью и уведомлении о готовности, эффективным провайдером, с экранированием.
|
||||||
|
Условия и события доставки (падение, review, готовность, рассинхрон) без
|
||||||
|
изменений.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Спеки:** дельта `notifications` — ADDED «Показ записи матча метабазы» +
|
||||||
|
MODIFIED «Экранирование внешнего текста» (в перечень добавлены id матча и URL,
|
||||||
|
экранирование учитывает контекст `href`/кавычку).
|
||||||
|
- **Код:** `internal/worker/review.go` (новые `ProviderURL` + метод `MatchURL`),
|
||||||
|
`internal/httpapi/review.go`/`download.go` (делегируют в worker; локальные
|
||||||
|
`providerURL`/`matchURL` удаляются, `sourceMatchURL` зовёт `worker.ProviderURL`),
|
||||||
|
`internal/tgbot/render.go` (`baseLine` по `ReviewData` со ссылкой; строка матча
|
||||||
|
в `renderDone`).
|
||||||
|
- **Тесты:** тесты `providerURL`/`matchURL` переезжают в `internal/worker`;
|
||||||
|
`internal/tgbot` — проверка ссылки и экранирования в строке матча.
|
||||||
|
- **Миграции БД:** нет (данные матча уже в БД).
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Показ записи матча метабазы в уведомлениях
|
||||||
|
|
||||||
|
Уведомления и подтверждения бота по загрузке с матчем метабазы система SHALL
|
||||||
|
сопровождать записью матча: provider и id, а при возможности построить URL
|
||||||
|
записи — ссылкой на страницу записи (тот же канонический билдер URL, что и веб).
|
||||||
|
Это SHALL применяться в карточке подтверждения (`review`) и в уведомлении о
|
||||||
|
готовности. Provider и id SHALL отражать эффективный выбор (с учётом ручных
|
||||||
|
правок), консистентно с веб-страницей загрузки и экраном ревью. Когда URL не
|
||||||
|
строится, матч SHALL показываться текстом (provider и id без ссылки), чтобы
|
||||||
|
ошибочную привязку было видно из бота.
|
||||||
|
|
||||||
|
Отсутствие матча (`none`/пусто) поверхности отражают по-разному: карточка
|
||||||
|
подтверждения SHALL показывать явный индикатор «нет матча» (в ревью полезно
|
||||||
|
видеть, что база не выбрана), а уведомление о готовности строку матча в этом
|
||||||
|
случае SHALL опускать (в финальном пинге «нет матча» — шум).
|
||||||
|
|
||||||
|
Provider, id и URL — недоверенные (метабаза/LLM/ручной ввод), поэтому система
|
||||||
|
MUST экранировать их перед вставкой в размеченное сообщение так, чтобы значение
|
||||||
|
не могло разорвать разметку в своём контексте (для URL в атрибуте `href` — с
|
||||||
|
учётом кавычки), иначе изготовленный id способен сломать сообщение и подавить
|
||||||
|
доставку уведомления (инвариант «выход LLM недоверенный»).
|
||||||
|
|
||||||
|
#### Scenario: Матч со ссылкой в карточке review
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с подтверждённым матчем метабазы, для которого
|
||||||
|
строится URL записи
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** матч выводится ссылкой на страницу записи с provider и id, а provider,
|
||||||
|
id и URL экранированы (в т.ч. кавычка в значении `href`)
|
||||||
|
|
||||||
|
#### Scenario: Матч без строящегося URL показывается текстом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка с матчем, для провайдера которого URL записи не строится
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** матч выводится текстом (provider и id) без ссылки
|
||||||
|
|
||||||
|
#### Scenario: Показ матча в уведомлении о готовности
|
||||||
|
|
||||||
|
- **GIVEN** загрузка с матчем метабазы, перешедшая в готовность
|
||||||
|
- **WHEN** бот рендерит уведомление о готовности
|
||||||
|
- **THEN** уведомление содержит запись матча (provider, id, при возможности —
|
||||||
|
ссылку), чтобы ошибочную привязку было видно после раскладки
|
||||||
|
|
||||||
|
#### Scenario: Нет матча — индикатор в review, пропуск в готовности
|
||||||
|
|
||||||
|
- **GIVEN** загрузка без матча метабазы (`none`/пусто)
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения, а затем уведомление о готовности
|
||||||
|
- **THEN** карточка подтверждения показывает индикатор «нет матча», а уведомление
|
||||||
|
о готовности строку матча не содержит
|
||||||
|
|
||||||
|
#### Scenario: Эффективный провайдер после ручной правки
|
||||||
|
|
||||||
|
- **GIVEN** загрузка, где провайдер/id матча переопределены вручную
|
||||||
|
- **WHEN** бот рендерит запись матча
|
||||||
|
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
|
||||||
|
а не значения сырого распознавания
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Экранирование внешнего текста при форматированных уведомлениях
|
||||||
|
|
||||||
|
При включённом форматировании исходящих сообщений (parse mode) система MUST
|
||||||
|
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
|
||||||
|
сообщение: display name, распознанное название, источник/контекст, целевой путь,
|
||||||
|
причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и
|
||||||
|
текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в
|
||||||
|
атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не
|
||||||
|
разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают
|
||||||
|
сообщение или что разметка будет инъектирована из недоверенного источника
|
||||||
|
(инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT
|
||||||
|
попадать в текст уведомлений и логи.
|
||||||
|
|
||||||
|
#### Scenario: Спецсимволы в названии не ломают разметку
|
||||||
|
|
||||||
|
- **GIVEN** уведомление, где display name или распознанное название содержит
|
||||||
|
символы разметки (`<`, `>`, `&`)
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
|
||||||
|
из недоверенного текста не интерпретируется
|
||||||
|
|
||||||
|
#### Scenario: Внешний путь и причины экранируются
|
||||||
|
|
||||||
|
- **GIVEN** уведомление с целевым путём плана и причинами распознавания
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** символы разметки в пути и причинах экранируются перед вставкой
|
||||||
|
|
||||||
|
#### Scenario: Кавычка в URL записи не разрывает атрибут href
|
||||||
|
|
||||||
|
- **GIVEN** уведомление со ссылкой на запись метабазы, где id (а значит URL)
|
||||||
|
содержит кавычку
|
||||||
|
- **WHEN** бот рендерит ссылку матча
|
||||||
|
- **THEN** кавычка в значении `href` экранируется, атрибут остаётся целым и
|
||||||
|
сообщение доставляется
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## 1. Ядро: общий билдер URL
|
||||||
|
|
||||||
|
- [x] 1.1 `internal/worker/review.go`: добавить экспортируемую
|
||||||
|
`func ProviderURL(provider, id, mediaType string) string` (канонический URL;
|
||||||
|
пустой id / неизвестный провайдер → пусто) и метод
|
||||||
|
`func (rd *ReviewData) MatchURL() string` (приоритет URL выбранного кандидата
|
||||||
|
при совпадении provider+id с эффективными, иначе `ProviderURL` по
|
||||||
|
`rd.Provider`/`rd.ProviderID`/`rd.Plan.Type`).
|
||||||
|
- [x] 1.2 Перенести тесты `providerURL`/`matchURL` в `internal/worker`
|
||||||
|
(из `internal/httpapi/providerurl_test.go`), поправив на новые имена/сигнатуры.
|
||||||
|
|
||||||
|
## 2. httpapi: делегирование
|
||||||
|
|
||||||
|
- [x] 2.1 `internal/httpapi/review.go`/`download.go`: удалить локальные
|
||||||
|
`providerURL`/`matchURL`; `view.MatchURL = rd.MatchURL()`; `sourceMatchURL`
|
||||||
|
зовёт `worker.ProviderURL(src.Provider, src.ProviderID, src.Type)`.
|
||||||
|
`parseProviderURL`/`parseManualSource` остаются в httpapi.
|
||||||
|
|
||||||
|
## 3. tgbot: показ матча
|
||||||
|
|
||||||
|
- [x] 3.1 `internal/tgbot/render.go`: `escHref` — экранирование URL для значения
|
||||||
|
атрибута `href` (поверх `esc` заменяет `"` → `"`).
|
||||||
|
- [x] 3.2 `baseLine` принимает `*worker.ReviewData`, использует эффективные
|
||||||
|
`rd.Provider`/`rd.ProviderID` и `rd.MatchURL()`: при непустом URL —
|
||||||
|
`<a href="...">provider id ↗</a>` (provider/id через `esc`, URL через `escHref`),
|
||||||
|
иначе текст `provider id`; при отсутствии матча — пусто.
|
||||||
|
- [x] 3.3 `reviewCard` зовёт `baseLine(rd)`, при пустом результате показывает
|
||||||
|
«База: нет матча» (сохранение поведения); `renderDone` добавляет строку
|
||||||
|
«База: …» только при непустом `baseLine(rd)` (без матча — опускает).
|
||||||
|
|
||||||
|
## 4. Тесты
|
||||||
|
|
||||||
|
- [x] 4.1 `internal/tgbot`: карточка ревью с матчем содержит `<a href=...↗`;
|
||||||
|
провайдер/id экранированы; матч без URL — текстом; `renderDone` содержит строку
|
||||||
|
матча при матче и опускает её без матча; карточка ревью без матча — «нет матча».
|
||||||
|
- [x] 4.2 `internal/tgbot`: id с `"` — кавычка в `href` экранируется (`"`),
|
||||||
|
атрибут остаётся целым.
|
||||||
|
|
||||||
|
## 5. Спека и проверки
|
||||||
|
|
||||||
|
- [x] 5.1 `openspec validate --strict telegram-metabase-match`.
|
||||||
|
- [x] 5.2 `task test` и `task lint` — зелёные.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## Контекст
|
||||||
|
|
||||||
|
Ядро выбора источника уже есть: `worker.ChooseCandidate(ctx, id, candidateID)`
|
||||||
|
пиннит кандидата (override provider/id/title/year/director), помечает `chosen`,
|
||||||
|
обновляет display_name — но **не** запускает раскладку (человек подтверждает
|
||||||
|
«Применить»). Веб-транспорт зовёт её из `handleChooseCandidate`, валидируя
|
||||||
|
`candidate_id` через `ident.Parse`. В боте механики выбора нет.
|
||||||
|
|
||||||
|
Карточка ревью в боте (`reviewCard`/`reviewKeyboard`) уже двухшаговая для
|
||||||
|
необратимых действий (delete/dismiss): первая кнопка меняет только клавиатуру
|
||||||
|
(`editMarkup`), подтверждение зовёт домен и обновляет карточку (`refreshCard`).
|
||||||
|
Тот же паттерн переиспользуем для выбора кандидата.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
**Поверхность.** В `reviewKeyboard`, когда `len(rd.Candidates) > 0`, добавляем
|
||||||
|
кнопку «🗂 База (N)» с callback `sources:<id>`. Она не трогает домен — через
|
||||||
|
`editMarkup` разворачивает клавиатуру списка кандидатов:
|
||||||
|
|
||||||
|
- по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`,
|
||||||
|
у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок
|
||||||
|
кнопок (кандидатов и так ≤ `maxCandidates`=8);
|
||||||
|
- callback кандидата — `pick:<downloadID>:<candidateID>`;
|
||||||
|
- нижняя кнопка «← Назад» с callback `srcback:<id>` — возвращает карточку
|
||||||
|
(`refreshCard`), домен не трогает.
|
||||||
|
|
||||||
|
**Обработка callback (`handleCallback`).**
|
||||||
|
|
||||||
|
- `sources` → `answer("")` + `editMarkup(candidatesKeyboard(rd))` (перечитываем
|
||||||
|
`ReviewData` ради свежего списка), домен не трогаем;
|
||||||
|
- `srcback` → `answer("Отменено")` + `refreshCard` (как `delete_cancel`);
|
||||||
|
- `pick` → `candidateID` из `val` (третий сегмент, `parseCallback` уже отдал его
|
||||||
|
как `value`) валидируем `ident.Parse` (невалидный → `answer("Кнопка устарела…")`,
|
||||||
|
состояние не меняем), затем
|
||||||
|
`reviewer.ChooseCandidate(ctx, id, candidateID)`; при успехе
|
||||||
|
`answer("Источник выбран") + refreshCard`, при ошибке — общая ветка ошибки.
|
||||||
|
|
||||||
|
**Интерфейс.** В `Reviewer` (bot.go) добавляем
|
||||||
|
`ChooseCandidate(ctx, id, candidateID string) error`; фейк в тестах дополняем.
|
||||||
|
|
||||||
|
## Границы и безопасность
|
||||||
|
|
||||||
|
- **Недоверенный вход:** `candidate_id` из `callback_data` валидируется как ULID
|
||||||
|
на границе транспорта (как в вебе). Принадлежность кандидата текущему
|
||||||
|
распознаванию доменно проверяет сам `ChooseCandidate` (не наш кандидат →
|
||||||
|
`ErrInvalidInput`).
|
||||||
|
- **Авто-раскладка не двигается:** `ChooseCandidate` только пиннит источник;
|
||||||
|
раскладка — отдельным «Применить». Инвариант «авто только по подтверждённому
|
||||||
|
матчу» не затрагивается.
|
||||||
|
- **Экранирование:** текст inline-кнопок Telegram НЕ парсится как HTML —
|
||||||
|
инъекция разметки из названия кандидата в кнопке невозможна; усечение длинных
|
||||||
|
названий — косметика. `sources`/`srcback` меняют только клавиатуру; `pick`
|
||||||
|
перерисовывает и текст карточки (`refreshCard` → `baseLine` с новым
|
||||||
|
provider/id), но новых **неэкранированных** фрагментов не появляется — `baseLine`
|
||||||
|
уже экранирует provider/id/URL (действующее требование «Показ записи матча…»).
|
||||||
|
|
||||||
|
## Бюджет callback_data
|
||||||
|
|
||||||
|
Лимит Telegram — 64 байта. Худший: `pick:<26>:<26>` = 4+1+26+1+26 = 58 ≤ 64.
|
||||||
|
`sources:<26>` = 34, `srcback:<26>` = 34. Запас есть.
|
||||||
|
|
||||||
|
## Отвергнутые альтернативы
|
||||||
|
|
||||||
|
- **Показывать «без базы» (нейтраль) и ручной ввод id в боте** — это точные
|
||||||
|
правки, зона веба (`ClearProvider`/`SetProviderID`); в боте раздули бы интерфейс
|
||||||
|
и клавиатуру. Бот — быстрый выбор из готового списка кандидатов.
|
||||||
|
- **Отдельное сообщение со списком вместо editMarkup** — расходится с уже
|
||||||
|
принятым двухшаговым паттерном (delete/dismiss) и плодит сообщения.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Распознавание уже копит кандидатов метабазы (`recognize.Result.Candidates` →
|
||||||
|
`metadata_candidate`), и веб даёт полный «единый список источников совпадения»
|
||||||
|
для точного выбора. В Telegram же карточка подтверждения показывает лишь
|
||||||
|
эффективный матч (строка «База: …» / «нет матча»), но **не даёт выбрать** между
|
||||||
|
несколькими найденными кандидатами: когда единичного сильного матча нет (частый
|
||||||
|
триггер ревью — «несколько кандидатов»), из бота нельзя быстро закрепить нужный,
|
||||||
|
приходится открывать веб. Ядро для этого уже готово: команда
|
||||||
|
`worker.ChooseCandidate` пиннит кандидата как источник (ручной матч), не
|
||||||
|
запуская авто-раскладку.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Карточка подтверждения (review)** в боте, когда для распознавания есть
|
||||||
|
сохранённые кандидаты метабазы, показывает кнопку выбора базы. По ней карточка
|
||||||
|
двухшагово (как подтверждение удаления) разворачивает **список кандидатов
|
||||||
|
inline-кнопками** — быстрый выбор из готового короткого списка.
|
||||||
|
- **Выбор кандидата** кнопкой вызывает `ChooseCandidate` (пиннинг источника,
|
||||||
|
ручной матч) и обновляет карточку на месте. Активный (уже выбранный) кандидат
|
||||||
|
помечается в списке. Кнопка «← Назад» возвращает карточку без изменений.
|
||||||
|
- **Веб остаётся точкой точных правок** (ввод id/URL вручную, «без базы»,
|
||||||
|
предпросмотр путей): бот — только быстрый выбор из уже найденных кандидатов.
|
||||||
|
- **Безопасность границы:** id кандидата приходит в `callback_data` — недоверенный
|
||||||
|
вход; перед доменным вызовом он валидируется как ULID (`ident.Parse`), как это
|
||||||
|
уже делает веб-транспорт. Выбор кандидата — ручной матч, авто-раскладку не
|
||||||
|
запускает (нужно явное «Применить»), инвариант авто-раскладки не двигается.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `notifications`: добавляется требование к **поверхности** бота — предлагать
|
||||||
|
выбор среди сохранённых кандидатов метабазы в карточке подтверждения
|
||||||
|
(inline-кнопки, двухшаговое разворачивание), с валидацией id кандидата из
|
||||||
|
`callback_data` на границе и без авто-раскладки. Доменная механика выбора
|
||||||
|
(`ChooseCandidate`, пиннинг источника) уже покрыта capability `review` — здесь
|
||||||
|
не дублируется.
|
||||||
|
- `review`: MODIFIED требование «Разделение труда транспортов в ревью» — быстрый
|
||||||
|
выбор источника из готового списка кандидатов теперь и Telegram-действие; точный
|
||||||
|
ручной ввод id/URL и «без базы» остаются за вебом. Снимает межспековое
|
||||||
|
расхождение (прежде выбор источника целиком относился к веб-точным-правкам).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Спеки:** дельта `notifications` — ADDED «Выбор кандидата метабазы из карточки
|
||||||
|
подтверждения бота»; дельта `review` — MODIFIED «Разделение труда транспортов в
|
||||||
|
ревью» (быстрый выбор кандидата — Telegram-действие).
|
||||||
|
- **Код:** `internal/tgbot/bot.go` (интерфейс `Reviewer` + `ChooseCandidate`;
|
||||||
|
callback-и списка/выбора/назад), `internal/tgbot/render.go` (кнопка выбора базы
|
||||||
|
в `reviewKeyboard`, клавиатура списка кандидатов).
|
||||||
|
- **Тесты:** `internal/tgbot` — карточка с кандидатами содержит кнопку выбора;
|
||||||
|
разворачивание даёт список; выбор зовёт `ChooseCandidate` с валидным id;
|
||||||
|
невалидный id из callback отклоняется.
|
||||||
|
- **Миграции БД:** нет (кандидаты уже в БД — таблица `metadata_candidate`).
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
|
||||||
|
|
||||||
|
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
|
||||||
|
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
|
||||||
|
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
|
||||||
|
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
|
||||||
|
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
|
||||||
|
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
|
||||||
|
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
|
||||||
|
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
|
||||||
|
карточку без изменений.
|
||||||
|
|
||||||
|
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
|
||||||
|
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
|
||||||
|
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
|
||||||
|
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
|
||||||
|
поверхности.
|
||||||
|
|
||||||
|
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
|
||||||
|
доменным вызовом выбора система MUST валидировать его как ULID на границе
|
||||||
|
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
|
||||||
|
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
|
||||||
|
кандидата в подписи кнопки инъекции разметки не создаёт.
|
||||||
|
|
||||||
|
#### Scenario: Карточка с кандидатами предлагает выбор базы
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
|
||||||
|
|
||||||
|
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
|
||||||
|
|
||||||
|
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
|
||||||
|
- **WHEN** пользователь нажимает кнопку кандидата
|
||||||
|
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
|
||||||
|
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
|
||||||
|
явное «Применить»
|
||||||
|
|
||||||
|
#### Scenario: Id кандидата из callback валидируется на границе
|
||||||
|
|
||||||
|
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
|
||||||
|
- **WHEN** бот обрабатывает callback
|
||||||
|
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
|
||||||
|
значение отклоняется без изменения состояния загрузки
|
||||||
|
|
||||||
|
#### Scenario: Возврат из списка не меняет состояние
|
||||||
|
|
||||||
|
- **GIVEN** развёрнутый в карточке список кандидатов
|
||||||
|
- **WHEN** пользователь нажимает кнопку возврата
|
||||||
|
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
|
||||||
|
|
||||||
|
#### Scenario: Без кандидатов кнопки выбора базы нет
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** карточка не содержит кнопку выбора базы
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Разделение труда транспортов в ревью
|
||||||
|
|
||||||
|
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||||
|
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
|
||||||
|
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
|
||||||
|
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
|
||||||
|
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
|
||||||
|
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
|
||||||
|
маппинг файлов), SHALL делаться в вебе.
|
||||||
|
|
||||||
|
#### Scenario: Эскалация из Telegram в веб
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, требующая точечного маппинга файлов
|
||||||
|
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||||
|
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||||
|
|
||||||
|
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
|
||||||
|
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
|
||||||
|
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
|
||||||
|
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## 1. Интерфейс транспорта
|
||||||
|
|
||||||
|
- [x] 1.1 `internal/tgbot/bot.go`: добавить в интерфейс `Reviewer` метод
|
||||||
|
`ChooseCandidate(ctx context.Context, id, candidateID string) error`.
|
||||||
|
- [x] 1.2 `internal/tgbot/bot_test.go`: дополнить `fakeReviewer` методом
|
||||||
|
`ChooseCandidate` (запоминает выбор для проверки).
|
||||||
|
|
||||||
|
## 2. Клавиатуры (render.go)
|
||||||
|
|
||||||
|
- [x] 2.1 `reviewKeyboard`: когда `len(rd.Candidates) > 0`, добавить кнопку
|
||||||
|
«🗂 База (N)» с callback `sources:<id>`.
|
||||||
|
- [x] 2.2 Новая `candidatesKeyboard(rd)`: по кнопке на кандидата (дедуп по
|
||||||
|
`provider:provider_id`, потолок), текст `provider · Title (Year)`, у `Chosen` —
|
||||||
|
префикс «✓ », callback `pick:<id>:<candidateID>`; нижняя кнопка «← Назад» с
|
||||||
|
callback `srcback:<id>`.
|
||||||
|
|
||||||
|
## 3. Обработка callback (bot.go)
|
||||||
|
|
||||||
|
- [x] 3.1 `handleCallback`: ветки `sources` (answer + `editMarkup`
|
||||||
|
списком кандидатов, перечитав `ReviewData`; домен не трогаем) и `srcback`
|
||||||
|
(answer «Отменено» + `refreshCard`).
|
||||||
|
- [x] 3.2 Ветка `pick`: валидировать `val` (id кандидата) через `ident.Parse`
|
||||||
|
(невалидный → answer «Кнопка устарела…», состояние не меняем), затем
|
||||||
|
`reviewer.ChooseCandidate(ctx, id, candidateID)`; успех → answer «Источник
|
||||||
|
выбран» + `refreshCard`; ошибка — общая ветка ошибки.
|
||||||
|
|
||||||
|
## 4. Тесты
|
||||||
|
|
||||||
|
- [x] 4.1 `internal/tgbot`: карточка ревью с кандидатами содержит кнопку
|
||||||
|
`sources:<id>`; без кандидатов — не содержит.
|
||||||
|
- [x] 4.2 `sources` разворачивает клавиатуру с кнопками `pick:<id>:<candID>` и
|
||||||
|
«← Назад»; выбранный кандидат помечен «✓ ».
|
||||||
|
- [x] 4.3 `pick` с валидным id зовёт `ChooseCandidate` с этим id и обновляет
|
||||||
|
карточку; `pick` с невалидным id — не зовёт домен.
|
||||||
|
|
||||||
|
## 5. Спека и проверки
|
||||||
|
|
||||||
|
- [x] 5.1 `openspec validate --strict telegram-vybor-nahodok`.
|
||||||
|
- [x] 5.2 `task test` и `task lint` — зелёные.
|
||||||
@@ -54,3 +54,173 @@ Telegram / бейдж в вебе) — пользователя зовут, а
|
|||||||
- **WHEN** задача переходит в `orphaned`
|
- **WHEN** задача переходит в `orphaned`
|
||||||
- **THEN** автор загрузки получает уведомление о рассинхроне
|
- **THEN** автор загрузки получает уведомление о рассинхроне
|
||||||
|
|
||||||
|
### Requirement: Download id в уведомлениях моноширинным для tap-to-copy
|
||||||
|
|
||||||
|
Уведомления, содержащие download id, система SHALL отображать id моноширинным
|
||||||
|
блоком (в Telegram — `<code>`), чтобы в клиенте работало tap-to-copy: id можно
|
||||||
|
скопировать одним касанием для перехода на `/download/{id}` или диагностики по
|
||||||
|
логам. Визуальный префикс (`#` / `download_id=`) SHALL оставаться вне
|
||||||
|
моноширинного блока, чтобы копировался чистый id без лишних символов.
|
||||||
|
|
||||||
|
#### Scenario: Id карточки review копируется одним касанием
|
||||||
|
|
||||||
|
- **GIVEN** уведомление о входе загрузки в `review` с download id
|
||||||
|
- **WHEN** бот рендерит сообщение
|
||||||
|
- **THEN** download id выводится моноширинным блоком (tap-to-copy), а префикс `#`
|
||||||
|
остаётся обычным текстом вне блока
|
||||||
|
|
||||||
|
#### Scenario: Id в сообщении об ошибке копируется
|
||||||
|
|
||||||
|
- **GIVEN** сообщение об отказе операции с `download_id`
|
||||||
|
- **WHEN** бот рендерит сообщение
|
||||||
|
- **THEN** значение download id выводится моноширинным блоком для копирования
|
||||||
|
|
||||||
|
### Requirement: Экранирование внешнего текста при форматированных уведомлениях
|
||||||
|
|
||||||
|
При включённом форматировании исходящих сообщений (parse mode) система MUST
|
||||||
|
экранировать все внешние/недоверенные фрагменты перед вставкой в размеченное
|
||||||
|
сообщение: display name, распознанное название, источник/контекст, целевой путь,
|
||||||
|
причины распознавания, provider, id матча, ссылку на запись метабазы (URL), код и
|
||||||
|
текст ошибки. Экранирование MUST учитывать контекст вставки: для значения в
|
||||||
|
атрибуте (URL в `href`) — в том числе кавычку, чтобы недоверенное значение не
|
||||||
|
разорвало атрибут. Это защищает от того, что спецсимволы разметки сломают
|
||||||
|
сообщение или что разметка будет инъектирована из недоверенного источника
|
||||||
|
(инвариант «выход LLM недоверенный»). Секреты (токены/ключи/пароли) MUST NOT
|
||||||
|
попадать в текст уведомлений и логи.
|
||||||
|
|
||||||
|
#### Scenario: Спецсимволы в названии не ломают разметку
|
||||||
|
|
||||||
|
- **GIVEN** уведомление, где display name или распознанное название содержит
|
||||||
|
символы разметки (`<`, `>`, `&`)
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** эти символы экранируются, сообщение доставляется корректно, а разметка
|
||||||
|
из недоверенного текста не интерпретируется
|
||||||
|
|
||||||
|
#### Scenario: Внешний путь и причины экранируются
|
||||||
|
|
||||||
|
- **GIVEN** уведомление с целевым путём плана и причинами распознавания
|
||||||
|
- **WHEN** бот рендерит форматированное сообщение
|
||||||
|
- **THEN** символы разметки в пути и причинах экранируются перед вставкой
|
||||||
|
|
||||||
|
#### Scenario: Кавычка в URL записи не разрывает атрибут href
|
||||||
|
|
||||||
|
- **GIVEN** уведомление со ссылкой на запись метабазы, где id (а значит URL)
|
||||||
|
содержит кавычку
|
||||||
|
- **WHEN** бот рендерит ссылку матча
|
||||||
|
- **THEN** кавычка в значении `href` экранируется, атрибут остаётся целым и
|
||||||
|
сообщение доставляется
|
||||||
|
|
||||||
|
### Requirement: Показ записи матча метабазы в уведомлениях
|
||||||
|
|
||||||
|
Уведомления и подтверждения бота по загрузке с матчем метабазы система SHALL
|
||||||
|
сопровождать записью матча: provider и id, а при возможности построить URL
|
||||||
|
записи — ссылкой на страницу записи (тот же канонический билдер URL, что и веб).
|
||||||
|
Это SHALL применяться в карточке подтверждения (`review`) и в уведомлении о
|
||||||
|
готовности. Provider и id SHALL отражать эффективный выбор (с учётом ручных
|
||||||
|
правок), консистентно с веб-страницей загрузки и экраном ревью. Когда URL не
|
||||||
|
строится, матч SHALL показываться текстом (provider и id без ссылки), чтобы
|
||||||
|
ошибочную привязку было видно из бота.
|
||||||
|
|
||||||
|
Отсутствие матча (`none`/пусто) поверхности отражают по-разному: карточка
|
||||||
|
подтверждения SHALL показывать явный индикатор «нет матча» (в ревью полезно
|
||||||
|
видеть, что база не выбрана), а уведомление о готовности строку матча в этом
|
||||||
|
случае SHALL опускать (в финальном пинге «нет матча» — шум).
|
||||||
|
|
||||||
|
Provider, id и URL — недоверенные (метабаза/LLM/ручной ввод), поэтому система
|
||||||
|
MUST экранировать их перед вставкой в размеченное сообщение так, чтобы значение
|
||||||
|
не могло разорвать разметку в своём контексте (для URL в атрибуте `href` — с
|
||||||
|
учётом кавычки), иначе изготовленный id способен сломать сообщение и подавить
|
||||||
|
доставку уведомления (инвариант «выход LLM недоверенный»).
|
||||||
|
|
||||||
|
#### Scenario: Матч со ссылкой в карточке review
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с подтверждённым матчем метабазы, для которого
|
||||||
|
строится URL записи
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** матч выводится ссылкой на страницу записи с provider и id, а provider,
|
||||||
|
id и URL экранированы (в т.ч. кавычка в значении `href`)
|
||||||
|
|
||||||
|
#### Scenario: Матч без строящегося URL показывается текстом
|
||||||
|
|
||||||
|
- **GIVEN** загрузка с матчем, для провайдера которого URL записи не строится
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** матч выводится текстом (provider и id) без ссылки
|
||||||
|
|
||||||
|
#### Scenario: Показ матча в уведомлении о готовности
|
||||||
|
|
||||||
|
- **GIVEN** загрузка с матчем метабазы, перешедшая в готовность
|
||||||
|
- **WHEN** бот рендерит уведомление о готовности
|
||||||
|
- **THEN** уведомление содержит запись матча (provider, id, при возможности —
|
||||||
|
ссылку), чтобы ошибочную привязку было видно после раскладки
|
||||||
|
|
||||||
|
#### Scenario: Нет матча — индикатор в review, пропуск в готовности
|
||||||
|
|
||||||
|
- **GIVEN** загрузка без матча метабазы (`none`/пусто)
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения, а затем уведомление о готовности
|
||||||
|
- **THEN** карточка подтверждения показывает индикатор «нет матча», а уведомление
|
||||||
|
о готовности строку матча не содержит
|
||||||
|
|
||||||
|
#### Scenario: Эффективный провайдер после ручной правки
|
||||||
|
|
||||||
|
- **GIVEN** загрузка, где провайдер/id матча переопределены вручную
|
||||||
|
- **WHEN** бот рендерит запись матча
|
||||||
|
- **THEN** показываются эффективные provider и id (как на веб-странице загрузки),
|
||||||
|
а не значения сырого распознавания
|
||||||
|
|
||||||
|
### Requirement: Выбор кандидата метабазы из карточки подтверждения бота
|
||||||
|
|
||||||
|
Карточка подтверждения бота SHALL предлагать выбрать источник из сохранённых
|
||||||
|
кандидатов метабазы inline-кнопками, когда для распознавания загрузки есть хотя
|
||||||
|
бы один такой кандидат (типичный триггер ревью — несколько подходящих записей без
|
||||||
|
единичного сильного матча), а не молча оставлять загрузку с первым/лучшим или без
|
||||||
|
базы. Когда сохранённых кандидатов нет, карточка кнопку выбора базы показывать
|
||||||
|
SHALL NOT. Выбор SHALL быть двухшаговым: кнопка выбора базы разворачивает список
|
||||||
|
кандидатов (по кнопке на кандидата, выбранный помечен), а нажатие кандидата
|
||||||
|
пиннит его как источник и обновляет карточку. Отдельная кнопка SHALL возвращать
|
||||||
|
карточку без изменений.
|
||||||
|
|
||||||
|
Выбор кандидата из бота — **ручной матч** (пиннинг источника): он SHALL NOT
|
||||||
|
запускать авто-раскладку; раскладка выполняется отдельным подтверждением
|
||||||
|
(«Применить»). Бот SHALL оставаться быстрым выбором из готового короткого списка
|
||||||
|
кандидатов, тогда как точные правки (ручной ввод id/URL, «без базы») — на веб-
|
||||||
|
поверхности.
|
||||||
|
|
||||||
|
Id кандидата приходит в `callback_data` и является недоверенным входом: перед
|
||||||
|
доменным вызовом выбора система MUST валидировать его как ULID на границе
|
||||||
|
транспорта; невалидный id MUST отклоняться без изменения состояния загрузки.
|
||||||
|
Текст inline-кнопок Telegram не интерпретируется как разметка, поэтому название
|
||||||
|
кандидата в подписи кнопки инъекции разметки не создаёт.
|
||||||
|
|
||||||
|
#### Scenario: Карточка с кандидатами предлагает выбор базы
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, для распознавания которой сохранены кандидаты метабазы
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** карточка содержит кнопку выбора базы, ведущую к списку кандидатов inline-кнопками
|
||||||
|
|
||||||
|
#### Scenario: Выбор кандидата пиннит источник без авто-раскладки
|
||||||
|
|
||||||
|
- **GIVEN** развёрнутый в карточке список кандидатов метабазы
|
||||||
|
- **WHEN** пользователь нажимает кнопку кандидата
|
||||||
|
- **THEN** этот кандидат закрепляется как источник (ручной матч), карточка
|
||||||
|
обновляется на месте, а авто-раскладка не запускается — для раскладки нужно
|
||||||
|
явное «Применить»
|
||||||
|
|
||||||
|
#### Scenario: Id кандидата из callback валидируется на границе
|
||||||
|
|
||||||
|
- **GIVEN** callback выбора кандидата, где значение id кандидата недоверенное/невалидное
|
||||||
|
- **WHEN** бот обрабатывает callback
|
||||||
|
- **THEN** id кандидата валидируется как ULID до доменного вызова, а невалидное
|
||||||
|
значение отклоняется без изменения состояния загрузки
|
||||||
|
|
||||||
|
#### Scenario: Возврат из списка не меняет состояние
|
||||||
|
|
||||||
|
- **GIVEN** развёрнутый в карточке список кандидатов
|
||||||
|
- **WHEN** пользователь нажимает кнопку возврата
|
||||||
|
- **THEN** бот возвращает исходную карточку подтверждения, не меняя выбранный источник
|
||||||
|
|
||||||
|
#### Scenario: Без кандидатов кнопки выбора базы нет
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review`, для распознавания которой кандидаты метабазы не сохранены
|
||||||
|
- **WHEN** бот рендерит карточку подтверждения
|
||||||
|
- **THEN** карточка не содержит кнопку выбора базы
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ LLM; нет матча в базе или несколько кандидато
|
|||||||
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
|
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
|
||||||
ссылки → `reverted`) и **Привязать заново** (из
|
ссылки → `reverted`) и **Привязать заново** (из
|
||||||
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
|
||||||
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
|
подтверждением). Никакая поверхность ревью — ни веб-UI, ни Telegram — MUST NOT
|
||||||
movie↔series: тип показывается read-only, а его корректировка выполняется
|
содержать команду переключения типа movie↔series: тип показывается read-only, а
|
||||||
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
|
его корректировка выполняется мягкой подсказкой через **Уточнить**
|
||||||
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
|
(перераспознавание, где пользователь явно указывает тип). Команды из любого
|
||||||
команда.
|
транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется
|
||||||
|
последняя валидная команда.
|
||||||
|
|
||||||
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
|
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
|
||||||
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
|
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
|
||||||
@@ -51,9 +52,9 @@ movie↔series: тип показывается read-only, а его корре
|
|||||||
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
|
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
|
||||||
|
|
||||||
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
|
||||||
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
|
заново**, **Привязать заново**), SHALL синхронно (без дебаунса) проверять перед
|
||||||
дебаунса) проверять перед действием, что источник не только присутствует в
|
действием, что источник не только присутствует в qBittorrent, но и **готов к
|
||||||
qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния
|
раскладке** — раздача в готовом классе состояния
|
||||||
(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5),
|
(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5),
|
||||||
т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное
|
т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное
|
||||||
или переходное `moving`/`checking` состояние), команда SHALL отказывать с
|
или переходное `moving`/`checking` состояние), команда SHALL отказывать с
|
||||||
@@ -78,12 +79,14 @@ qBittorrent, но и **готов к раскладке** — раздача в
|
|||||||
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
|
||||||
подтверждением (авто-раскладка не делается)
|
подтверждением (авто-раскладка не делается)
|
||||||
|
|
||||||
#### Scenario: Тип не переключается кнопкой
|
#### Scenario: Тип не переключается командой ни в одном транспорте
|
||||||
|
|
||||||
- **GIVEN** загрузка в `review` с распознанным типом
|
- **GIVEN** загрузка в `review` с распознанным типом
|
||||||
- **WHEN** пользователь открывает экран ревью
|
- **WHEN** пользователь открывает ревью в вебе или в Telegram
|
||||||
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
|
- **THEN** отдельной команды/кнопки переключения movie↔series нет ни на одной
|
||||||
- **AND** тип показан read-only в инфо-части выбранного источника
|
поверхности
|
||||||
|
- **AND** тип показан read-only; для смены типа пользователь уточняет
|
||||||
|
распознавание («Уточнить», явно указав тип)
|
||||||
|
|
||||||
#### Scenario: Позже паркует задачу из ревью
|
#### Scenario: Позже паркует задачу из ревью
|
||||||
|
|
||||||
@@ -107,7 +110,7 @@ qBittorrent, но и **готов к раскладке** — раздача в
|
|||||||
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
|
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
|
||||||
качается (`downloading`, файлы не докачаны)
|
качается (`downloading`, файлы не докачаны)
|
||||||
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
|
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
|
||||||
заново»/фиксацию типа)
|
заново»)
|
||||||
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
|
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
|
||||||
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
|
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
|
||||||
не запускается
|
не запускается
|
||||||
@@ -129,9 +132,11 @@ qBittorrent, но и **готов к раскладке** — раздача в
|
|||||||
|
|
||||||
#### Scenario: Override переживает перераспознавание
|
#### Scenario: Override переживает перераспознавание
|
||||||
|
|
||||||
- **GIVEN** пользователь зафиксировал тип `series` как override
|
- **GIVEN** пользователь закрепил источник (кандидат метабазы) как эффективный
|
||||||
|
матч
|
||||||
- **WHEN** запускается перераспознавание по новой подсказке
|
- **WHEN** запускается перераспознавание по новой подсказке
|
||||||
- **THEN** в новом эффективном плане тип остаётся `series`
|
- **THEN** в новом эффективном плане закреплённые название/год/провайдер
|
||||||
|
остаются
|
||||||
|
|
||||||
### Requirement: Единый список источников совпадения на ревью
|
### Requirement: Единый список источников совпадения на ревью
|
||||||
|
|
||||||
@@ -329,10 +334,12 @@ SHALL относиться именно к активному источнику
|
|||||||
### Requirement: Разделение труда транспортов в ревью
|
### Requirement: Разделение труда транспортов в ревью
|
||||||
|
|
||||||
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
|
||||||
быть поверхностью точных правок (маппинг файлов, выбор/ввод источника,
|
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
|
||||||
предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать,
|
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
|
||||||
переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
(одобрить, подсказать, **быстрый выбор источника из готового списка кандидатов
|
||||||
страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе.
|
метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
|
||||||
|
страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, маппинг
|
||||||
|
файлов), SHALL делаться в вебе.
|
||||||
|
|
||||||
#### Scenario: Эскалация из Telegram в веб
|
#### Scenario: Эскалация из Telegram в веб
|
||||||
|
|
||||||
@@ -340,6 +347,13 @@ SHALL относиться именно к активному источнику
|
|||||||
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
- **WHEN** пользователь в Telegram выбирает «В вебе»
|
||||||
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
|
||||||
|
|
||||||
|
#### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы
|
||||||
|
- **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram
|
||||||
|
- **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что
|
||||||
|
и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба
|
||||||
|
|
||||||
### Requirement: Петлевые действия ревью обновляют экран на месте
|
### Requirement: Петлевые действия ревью обновляют экран на месте
|
||||||
|
|
||||||
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).
|
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).
|
||||||
|
|||||||
Reference in New Issue
Block a user