Имя: восстановление 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
+34 -6
View File
@@ -191,6 +191,15 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
return nil
}
func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.DisplayName = name
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {
@@ -258,12 +267,20 @@ func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCa
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
added []qbt.AddRequest
addErr error
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deleteErr error
torrents []qbt.Torrent
torrentsErr error
added []qbt.AddRequest
addErr error
files []qbt.File
deleted [][]string // хеши каждого вызова Delete
deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error
}
type renameCall struct {
hash string
name string
}
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
@@ -271,6 +288,9 @@ type fakeQbt struct {
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if f.torrentsErr != nil {
return nil, f.torrentsErr
}
if category == "" {
return f.torrents, nil
}
@@ -303,6 +323,14 @@ func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
return nil
}
func (f *fakeQbt) RenameTorrent(_ context.Context, hash, name string) error {
if f.renameErr != nil {
return f.renameErr
}
f.renamed = append(f.renamed, renameCall{hash, name})
return nil
}
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
w := New(st, qb, nil, nil, Config{
Category: "jellybit",