Compare commits

..
6 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 1d6f8f3449 review: убрать явный переключатель типа movie↔series из всех транспортов
Кнопка «Тип» была только в Telegram (доменная SetType = жёсткий override
media_type + хинт + перераспознавание), в вебе её нет. Это создавало
расхождение поверхностей и внутреннюю противоречивость спеки review.
Решение: смена типа — редкий случай, для него достаточно «Уточнить»
(перераспознавание с явным указанием типа). Явный переключатель не нужен
ни на одной поверхности.

Снято: Telegram-кнопка и callback type:, worker.SetType, ставший мёртвым
override-плумбинг media_type (ovrMediaType, ветка applyOverrides, хелпер
oppositeType) и стейл-хвосты в тестах httpapi. Спека review — три MODIFIED
требования (запрет на все поверхности, «фиксация типа» убрана из команд и
из быстрых действий Telegram, иллюстрация override заменена на закрепление
источника). Синхронизирован docs/specs/review-ux.md.

Change заархивирован: openspec/changes/archive/2026-07-18-review-remove-type-switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:50:43 +03:00
avandClaude Opus 4.8 023fdacda5 tgbot: тесты на экранирование пути/причин и формат download_id в opErr
Финальная сверка спек notifications отметила два сценария спеки без
прицельного теста (спецсимволы в пути раскладки/причинах, формат id в
opErr) — поведение верное, но регресс не ловился. Закрываю тестами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:09:48 +03:00
avandClaude Opus 4.8 f75d6b1f91 tgbot: выбор кандидата метабазы inline-кнопками в карточке ревью
Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения
бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss)
разворачивается список кандидатов inline-кнопками; выбор пиннит источник через
worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку.
Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»).

Безопасность границы: id кандидата из callback_data валидируется как ULID
(ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не
парсится как HTML — название кандидата в подписи не экранируется.

SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор
кандидата метабазы из карточки подтверждения бота») + review (MODIFIED
«Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие).
Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:02:47 +03:00
avandClaude Opus 4.8 10a6348d39 tgbot: показывать запись матча метабазы (провайдер+id+ссылка)
В карточке подтверждения и уведомлении о готовности бот теперь показывает
запись матча метабазы — провайдер, id и кликабельную ссылку на страницу
записи, — как веб-страница /download/{id} и экран ревью. Так ошибочную
привязку видно и из Telegram.

Билдер URL записи (providerURL/matchURL) вынесен из internal/httpapi в ядро
internal/worker (worker.ProviderURL + метод (*ReviewData).MatchURL()), чтобы
оба транспорта строили ссылку одинаково; httpapi делегирует туда. baseLine
переведён на эффективные provider/id (с учётом ручных правок), URL в href
экранируется escHref (сверх esc закрывает кавычку — иначе изготовленный id
разорвал бы атрибут и Telegram отклонил бы сообщение). При отсутствии матча
карточка ревью показывает «нет матча», уведомление о готовности строку
опускает.

Capability notifications: ADDED «Показ записи матча метабазы» + MODIFIED
требования об экранировании (id матча и URL, контекст href).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:41:28 +03:00
avandClaude Opus 4.8 4a58b0bda0 tgbot: download id моноширинным (code) для tap-to-copy
Включён HTML parse mode у всех исходящих сообщений бота (send + edit-путь
refreshCard), download id выводится моноширинным <code> — в клиентах Telegram
по нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
Префикс # / download_id= остаётся вне <code>, чтобы копировался чистый id.

Parse mode делает разметку значимой для всех текстов, поэтому добавлен
escape-хелпер и экранированы все внешние/недоверенные фрагменты: display name,
распознанное название, источник/контекст, целевой путь, причины, provider,
error_code/error_msg (инвариант «выход LLM недоверенный»). esc применяется
последним шагом, после усечения, чтобы обрез не разрубил HTML-сущность.

Capability notifications: два ADDED-требования (формат id + экранирование).
Беклог: задача закрыта, зонтичный telegram-revyu-uvedomleniy обновлён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:17:26 +03:00
av 85e27b28e2 Добавил задачу про расхождение нумерации в Бибопе 2026-07-18 14:53:12 +03:00
38 changed files with 1825 additions and 309 deletions
+1 -3
View File
@@ -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).
-21
View File
@@ -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`).
-7
View File
@@ -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 → «Транспорты».
+3 -2
View File
@@ -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]].
-7
View File
@@ -1,7 +0,0 @@
# Выбор из нескольких находок метабазы в Telegram
**Приоритет:** низкий
Когда распознавание даёт несколько подходящих кандидатов в метабазе, предлагать их в Telegram списком (кнопки) для ручного выбора, а не молча брать первый/лучший. Веб остаётся точкой точных правок (полный выбор источника уже реализован), бот — быстрый выбор из готового короткого списка.
Связано: specs/review-ux.md (боты — быстрые действия, веб — точные правки), specs/recognition.md (кандидаты матча).
+4 -3
View File
@@ -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,
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
-53
View File
@@ -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)
}
}
-1
View File
@@ -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 }
+4 -45
View File
@@ -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
View File
@@ -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 + "."
} }
+247 -22
View File
@@ -2,6 +2,7 @@ package tgbot
import ( import (
"context" "context"
"database/sql"
"io" "io"
"log/slog" "log/slog"
"strings" "strings"
@@ -27,17 +28,18 @@ type fakeAPI struct {
} }
type sentMsg struct { 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 &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
t.Errorf("название не экранировано: %q", msg.text)
}
if strings.Contains(msg.text, "<b>") {
t.Errorf("сырая разметка просочилась: %q", msg.text)
}
// Усечённый источник: сущность на границе цела (esc после shorten даёт
// «&amp;…», а не разрубленное «&am…»).
if !strings.Contains(msg.text, "&amp;…") {
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 &amp; B &lt;x&gt;»") {
t.Errorf("название failed не экранировано: %q", msg.text)
}
if !strings.Contains(msg.text, "path &lt;bad&gt; &amp; 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 &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
t.Errorf("причины не экранированы:\n%s", text)
}
if !strings.Contains(text, "A &amp; B &lt;x&gt;") || !strings.Contains(text, "file &lt;y&gt;.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})
+1 -1
View File
@@ -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
View File
@@ -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), чтобы обрез не разрубил сущность
// `&lt;` на битую разметку.
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 заменяем `"` на
// `&quot;`; порядок безопасен: esc уже перевёл `&` в `&amp;`, повторно `&` в
// `&quot;` не удвоится.
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, "&quot;") }
// 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]
+109
View File
@@ -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 (&quot;), атрибут
// остаётся целым — иначе 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, "&quot;") {
t.Errorf("кавычка в href не экранирована:\n%s", text)
}
}
+70
View File
@@ -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
View File
@@ -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
} }
+5 -27
View File
@@ -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")
@@ -1582,18 +1559,19 @@ 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)
+1 -1
View File
@@ -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>`), добавить проверку экранирования спецсимволов во внешнем фрагменте.
- **Миграции БД:** нет.
@@ -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`), иначе обрез посреди сущности
`&lt;` даст битую разметку → 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`
дополнительно заменяет `"` на `&quot;` (порядок безопасен: `esc` уже перевёл `&` в
`&amp;`, так что `&` в `&quot;` не удвоится). Ссылка рисуется только при непустом
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` — проверка ссылки и экранирования в строке матча.
- **Миграции БД:** нет (данные матча уже в БД).
@@ -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` заменяет `"``&quot;`).
- [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` экранируется (`&quot;`),
атрибут остаётся целым.
## 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`).
@@ -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` — зелёные.
+170
View File
@@ -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 -19
View File
@@ -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 переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).