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