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>
This commit is contained in:
av
2026-07-18 16:50:43 +03:00
co-authored by Claude Opus 4.8
parent 023fdacda5
commit 1d6f8f3449
13 changed files with 252 additions and 123 deletions
+4 -3
View File
@@ -121,7 +121,8 @@ Telegram = одобрить / подсказать / выбрать кандид
- **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с
абсолютной нумерацией → веб-хелпер «absolute → S·E»
([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)).
- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана.
- **Не тот тип (movie↔series)** → «Уточнить» с явным указанием типа
перераспознаёт план (отдельного переключателя типа нет — тип read-only).
- **Мусор (sample/extra/дубли дорожек)** → роль «игнор».
- **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать
тип, ввести название/год, разложить файлы руками; в Telegram — сразу
@@ -152,14 +153,14 @@ Telegram = одобрить / подсказать / выбрать кандид
## Объём по версиям
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
заново», переключатель типа, **единый список источников совпадения**
заново», **единый список источников совпадения**
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
источника до применения** (место под режиссёра зарезервировано), пометка
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
В Telegram — подтверждение с reply-подсказкой
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
(«Уточнить»), «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
+1 -1
View File
@@ -265,7 +265,7 @@ func TestAPICommandNotReady(t *testing.T) {
func TestAPICommandInvalidInput(t *testing.T) {
// Промах пользователя (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{}})
resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
-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) 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) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
-4
View File
@@ -44,7 +44,6 @@ type Reviewer interface {
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
@@ -355,9 +354,6 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "dismiss_confirm":
err = b.reviewer.Dismiss(ctx, id)
note = "Закрываю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
+2 -18
View File
@@ -67,7 +67,6 @@ type fakeReviewer struct {
data *worker.ReviewData
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
@@ -97,13 +96,6 @@ func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
@@ -270,14 +262,6 @@ func TestBot_CallbackApply(t *testing.T) {
}
}
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)
}
}
func TestBot_CallbackRefineSetsPending(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
@@ -457,8 +441,8 @@ func TestBot_CallbackRetry(t *testing.T) {
}
func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:" + tid + ":series")
if a != "type" || id != tid || v != "series" {
a, id, v := parseCallback("pick:" + tid + ":" + tid)
if a != "pick" || id != tid || v != tid {
t.Errorf("got %q %q %q", a, id, v)
}
a, id, v = parseCallback("apply:" + tid)
-8
View File
@@ -106,7 +106,6 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
if n := len(dedupCandidates(rd.Candidates)); n > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
}
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
row2 := tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
@@ -382,13 +381,6 @@ func contextOrSource(rd *worker.ReviewData) string {
return rd.Download.SourceRef
}
func oppositeType(t string) string {
if t == "series" {
return "movie"
}
return "series"
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
+4 -41
View File
@@ -23,7 +23,6 @@ import (
// Поля override.
const (
ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе
@@ -475,38 +474,6 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
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;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
@@ -1148,8 +1115,7 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc
}
// 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 {
m := make(map[string]string, len(base)+len(pins))
for k, v := range base {
@@ -1307,13 +1273,10 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
// --- Хелперы преобразования ---
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
// ignore (их раскладка пропустит).
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
// раскладка пропустит).
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 != "" {
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") }},
{"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 {
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) {
st := newMemStore()
d := completedDownload("1")
@@ -1582,18 +1559,19 @@ func TestReviewData(t *testing.T) {
func TestApplyOverrides(t *testing.T) {
plan := recognize.Plan{
Type: recognize.MediaMovie,
Type: recognize.MediaMovie,
Title: "Сырое название",
Files: []recognize.PlanFile{
{Src: "a.mkv", Role: recognize.RoleMain},
{Src: "b.mkv", Role: recognize.RoleEpisode},
},
}
out := applyOverrides(plan, map[string]string{
ovrMediaType: "series",
ovrTitle: "Каноническое название",
ovrIgnoredFiles: `["a.mkv"]`,
})
if out.Type != recognize.MediaSeries {
t.Errorf("type = %q, want series", out.Type)
if out.Title != "Каноническое название" {
t.Errorf("title = %q, want override", out.Title)
}
if out.Files[0].Role != "ignore" {
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 по таймаутам и
// ошибкам qBittorrent. Ф3 продолжает: completed → recognizing (вызов
// recognize) → review; команды ревью (apply/refine/reject/defer/undo,
// переключение типа, пометка «игнор») раскладывают файлы хардлинками через
// пометка «игнор») раскладывают файлы хардлинками через
// layout. Распознавание зовётся в поллинг-цикле, команды — из транспортов;
// всё под per-download блокировкой w.mu.
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.
+24 -19
View File
@@ -33,11 +33,12 @@ LLM; нет матча в базе или несколько кандидато
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда.
подтверждением). Никакая поверхность ревью — ни веб-UI, ни Telegram — MUST NOT
содержать команду переключения типа movie↔series: тип показывается read-only, а
его корректировка выполняется мягкой подсказкой через **Уточнить**
(перераспознавание, где пользователь явно указывает тип). Команды из любого
транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется
последняя валидная команда.
Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого
не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL
@@ -51,9 +52,9 @@ movie↔series: тип показывается read-only, а его корре
Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`).
Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать
заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без
дебаунса) проверять перед действием, что источник не только присутствует в
qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния
заново**, **Привязать заново**), SHALL синхронно (без дебаунса) проверять перед
действием, что источник не только присутствует в qBittorrent, но и **готов к
раскладке** — раздача в готовом классе состояния
(`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5),
т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное
или переходное `moving`/`checking` состояние), команда SHALL отказывать с
@@ -78,12 +79,14 @@ qBittorrent, но и **готов к раскладке** — раздача в
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
#### Scenario: Тип не переключается кнопкой
#### Scenario: Тип не переключается командой ни в одном транспорте
- **GIVEN** загрузка в `review` с распознанным типом
- **WHEN** пользователь открывает экран ревью
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника
- **WHEN** пользователь открывает ревью в вебе или в Telegram
- **THEN** отдельной команды/кнопки переключения movie↔series нет ни на одной
поверхности
- **AND** тип показан read-only; для смены типа пользователь уточняет
распознавание («Уточнить», явно указав тип)
#### Scenario: Позже паркует задачу из ревью
@@ -107,7 +110,7 @@ qBittorrent, но и **готов к раскладке** — раздача в
- **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё
качается (`downloading`, файлы не докачаны)
- **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать
заново»/фиксацию типа)
заново»)
- **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается»
- **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка
не запускается
@@ -129,9 +132,11 @@ qBittorrent, но и **готов к раскладке** — раздача в
#### Scenario: Override переживает перераспознавание
- **GIVEN** пользователь зафиксировал тип `series` как override
- **GIVEN** пользователь закрепил источник (кандидат метабазы) как эффективный
матч
- **WHEN** запускается перераспознавание по новой подсказке
- **THEN** в новом эффективном плане тип остаётся `series`
- **THEN** в новом эффективном плане закреплённые название/год/провайдер
остаются
### Requirement: Единый список источников совпадения на ревью
@@ -331,10 +336,10 @@ SHALL относиться именно к активному источнику
Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL
быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по
id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия
(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового
списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом
на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL,
маппинг файлов), SHALL делаться в вебе.
(одобрить, подсказать, **быстрый выбор источника из готового списка кандидатов
метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же
страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, маппинг
файлов), SHALL делаться в вебе.
#### Scenario: Эскалация из Telegram в веб