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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, "Жду подсказку")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -1583,17 +1560,18 @@ func TestReviewData(t *testing.T) {
|
||||
func TestApplyOverrides(t *testing.T) {
|
||||
plan := recognize.Plan{
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 в веб
|
||||
|
||||
|
||||
Reference in New Issue
Block a user