Имя: восстановление 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
+143
View File
@@ -463,6 +463,13 @@ func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) err
return nil
}
func (m *memStore) SetDisplayName(_ context.Context, id, name string) error {
if d, ok := m.downloads[id]; ok {
d.DisplayName = name
}
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
@@ -1621,6 +1628,142 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
}
}
// refreshSetup: загрузка в review с распознаванием (movie «Догадка»/2000),
// display_name="Unknown", кандидатом cand и раздачей в qBit по ihTest — для
// проверок перелива имени после подтверждения матча.
func refreshSetup(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore, *fakeQbt) {
t.Helper()
st := newMemStore()
d := completedDownload("1")
d.State = store.StateReview
d.DisplayName = "Unknown"
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"),
})
cand.RecognitionID = "1"
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
// Ключ раздачи в qBit (Hash) НАМЕРЕННО отличается от нашего инфохэша: matched
// по InfohashV1. Так тест ловит, что rename идёт по реальному t.Hash (мотив
// D3: для гибрид/v2 t.Hash ≠ PrimaryInfohash), а не по сырому инфохэшу.
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: qbtHash, InfohashV1: ihTest, Name: "Unknown"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
return w, st, qb
}
// qbtHash — внутренний ключ раздачи в qBittorrent, отличный от инфохэша загрузки.
const qbtHash = "qbt-internal-key"
func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
const want = "Harold and the Purple Crayon (2024)"
if got := st.downloads["1"].DisplayName; got != want {
t.Errorf("display_name = %q, want %q", got, want)
}
// rename адресован по реальному t.Hash (qbtHash), а НЕ по инфохэшу загрузки.
if len(qb.renamed) != 1 || qb.renamed[0].hash != qbtHash || qb.renamed[0].name != want {
t.Errorf("renamed = %+v, want hash=%s name=%q", qb.renamed, qbtHash, want)
}
if qbtHash == ihTest {
t.Fatal("тест бессмыслен: qbtHash совпал с инфохэшем загрузки")
}
}
func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.renameErr = errors.New("qbit down")
// Best-effort: сбой переименования раздачи не должен ронять выбор кандидата.
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit rename failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой qBit)", got)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentMissing(t *testing.T) {
// Раздача удалена (нет в qBit) — display_name всё равно обновлён, rename нет.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrents = nil // torrentByInfohash не найдёт раздачу → best-effort no-op
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён и без раздачи)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (раздачи нет)", qb.renamed)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentsLookupError(t *testing.T) {
// Листинг qBit упал — display_name обновлён, rename не зовём, команда цела.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrentsErr = errors.New("qbit down")
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit lookup failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой листинга)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (листинг упал)", qb.renamed)
}
}
func TestAddManualSource_RefreshesDisplayName(t *testing.T) {
// Ручное добавление источника — тоже подтверждение матча → refresh.
// Кандидат без title/year → имя берётся из распознавания (fallback).
w, st, qb := refreshSetup(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
if err := w.AddManualSource(context.Background(), "1", "tmdb", "603"); err != nil {
t.Fatalf("AddManualSource: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Догадка (2000)" {
t.Errorf("display_name = %q, want fallback «Догадка (2000)»", got)
}
if len(qb.renamed) != 1 || qb.renamed[0].name != "Догадка (2000)" {
t.Errorf("renamed = %+v", qb.renamed)
}
}
func TestRefreshDisplayName_NoRecognitionNoop(t *testing.T) {
// Нет распознавания — обновлять нечего: display_name не трогаем, rename нет.
st := newMemStore()
d := completedDownload("1")
d.State = store.StateDownloading
d.DisplayName = ""
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "raw"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.RefreshDisplayName(context.Background(), "1"); err != nil {
t.Fatalf("RefreshDisplayName: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "" {
t.Errorf("display_name = %q, want empty (no-op)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none", qb.renamed)
}
}
func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {