Имя: восстановление 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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user