Имя: восстановление 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:
@@ -719,6 +719,21 @@ WHERE id = ? AND state = ?`,
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDisplayName обновляет отображаемое имя загрузки постфактум — перелив
|
||||
// канонического имени после распознавания (см. capability ingest). В отличие от
|
||||
// PromoteCatched не двигает FSM и не завязан на состояние (гарда state нет):
|
||||
// обновление имени валидно и в review, и в терминальных done/orphaned. Имя —
|
||||
// косметика (заголовок в UI + ярлык раздачи), пути на диске не затрагивает.
|
||||
func (s *Store) SetDisplayName(ctx context.Context, id, name string) error {
|
||||
if _, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE download
|
||||
SET display_name = ?, updated_at = ?
|
||||
WHERE id = ?`, name, FormatTime(Now()), id); err != nil {
|
||||
return fmt.Errorf("set display name %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
|
||||
@@ -37,6 +37,34 @@ func newCatched() *Download {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDisplayNamePostHoc(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
d := newDownloading()
|
||||
d.DisplayName = "Unknown"
|
||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil {
|
||||
t.Fatalf("create: existing=%v err=%v", existing, err)
|
||||
}
|
||||
// Терминальное состояние — SetDisplayName не завязан на state (в отличие от
|
||||
// PromoteCatched) и должен обновлять имя и на done.
|
||||
forceState(t, st, d.ID, StateDone)
|
||||
|
||||
if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownload: %v", err)
|
||||
}
|
||||
if got.DisplayName != "Harold and the Purple Crayon (2024)" {
|
||||
t.Errorf("display_name = %q", got.DisplayName)
|
||||
}
|
||||
if got.State != StateDone {
|
||||
t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// catched — нетерминальное активное состояние: его наличие блокирует повторный
|
||||
// приём того же infohash (инвариант «≤1 активная на infohash»).
|
||||
func TestCatchedIsActiveForDedup(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user