Имя: восстановление display_name после распознавания + гард пустого входа

Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 16:51:04 +03:00
co-authored by Claude Opus 4.8
parent e2ea1840c9
commit 0c9421f4c1
26 changed files with 1179 additions and 21 deletions
+8
View File
@@ -47,6 +47,11 @@ type downloadDetailView struct {
// Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView
// Nameable — доступно ручное обновление имени: есть распознанное название,
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
Nameable bool
// Действия по состоянию (как на главной).
Terminal bool
Reviewable bool
@@ -132,6 +137,9 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.Season = seasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
+1
View File
@@ -134,6 +134,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
+100 -14
View File
@@ -479,20 +479,21 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
// --- Ревью ---
type fakeReviewer struct {
data *worker.ReviewData
applyErr error
refined map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
deleted []string
relinked []string
rerecognized []string
cleared []string
data *worker.ReviewData
applyErr error
refined map[string]string
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
deleted []string
relinked []string
rerecognized []string
cleared []string
nameRefreshed []string
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
@@ -564,6 +565,10 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
}
func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
f.nameRefreshed = append(f.nameRefreshed, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
@@ -717,6 +722,87 @@ func TestAddManualSource(t *testing.T) {
}
}
func TestRefreshNameNonHTMXRedirectsToDownload(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
// Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список.
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
t.Errorf("Location = %q, want /download/%s", loc, tid)
}
}
func TestRefreshNameHTMXSwapsMain(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
req.Header.Set("HX-Request", "true")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `id="download-main"`) {
t.Error("htmx-ответ не содержит фрагмент download-main")
}
if len(rv.nameRefreshed) != 1 {
t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
}
}
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
// done (есть план) она всё равно доступна.
rd := seriesReviewData()
rd.Download.State = store.StateDone
rv := &fakeReviewer{data: rd}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "/refresh-name") {
t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания")
}
}
func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) {
rv := &fakeReviewer{data: &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"},
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := http.Get(srv.URL + "/download/" + tid)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "/refresh-name") {
t.Error("кнопка «Обновить имя» показана без распознавания")
}
}
func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
+2 -1
View File
@@ -57,7 +57,8 @@ func (stubReviewer) SetProviderID(context.Context, string, string, string) error
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
return nil
}
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
func (stubReviewer) RefreshDisplayName(context.Context, string) error { return nil }
// stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live }
+24
View File
@@ -28,6 +28,7 @@ type Reviewer interface {
SetProviderID(ctx context.Context, id string, provider, providerID string) error
AddManualSource(ctx context.Context, id, provider, providerID string) error
ClearProvider(ctx context.Context, id string) error
RefreshDisplayName(ctx context.Context, id string) error
}
// --- Представление страницы ревью ---
@@ -229,6 +230,29 @@ func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
})
}
// handleRefreshName переливает распознанное каноническое имя в display_name и в
// ярлык раздачи (ручная кнопка на странице загрузки). Свопит главную область
// (#download-main) с обновлённым заголовком; без htmx — PRG на страницу загрузки
// (в отличие от surfaceAction, уводящего на список: смысл действия — увидеть
// новое имя здесь же). Ошибку на htmx-пути показываем 200 + фрагментом.
func (s *server) handleRefreshName(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := s.deps.Reviewer.RefreshDisplayName(r.Context(), id)
if !isHTMX(r) {
if actionErr != nil {
redirectErr(w, r, userErr(r, actionErr, id))
return
}
http.Redirect(w, r, "/download/"+id, http.StatusSeeOther)
return
}
s.renderDownloadFragment(w, r, id, actionErr)
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")