Имя: восстановление 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:
@@ -15,6 +15,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -794,6 +795,13 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
|
||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||
// выбор кандидата: логируем и продолжаем.
|
||||
if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
|
||||
Warn("display name refresh after candidate choice failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1064,6 +1072,67 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P
|
||||
return applyOverrides(plan, overrides), prov, pid, nil
|
||||
}
|
||||
|
||||
// RefreshDisplayName — внешняя точка входа обновления отображаемого имени
|
||||
// (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked.
|
||||
func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) {
|
||||
defer func() { w.logCmd(ctx, "refresh_name", id, err) }()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.refreshDisplayNameLocked(ctx, id)
|
||||
}
|
||||
|
||||
// refreshDisplayNameLocked переливает уже вычисленное каноническое имя
|
||||
// (эффективный план с учётом пинов) в download.display_name и в имя раздачи
|
||||
// qBittorrent — без нового вызова LLM. Косметика: не влияет на пути/раскладку.
|
||||
// Best-effort к qBittorrent: недоступность/отсутствие раздачи не проваливает
|
||||
// операцию (display_name пишется в любом случае). Вызывается под w.mu.
|
||||
func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
if d == nil {
|
||||
return fmt.Errorf("refresh display name: download %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
cctx := w.scoped(ctx, capIngest, id, d.PrimaryInfohash())
|
||||
|
||||
rec, err := w.store.GetCurrentRecognition(cctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
if rec == nil || !rec.Plan.Valid {
|
||||
return nil // распознавания ещё нет — обновлять нечего (no-op)
|
||||
}
|
||||
plan, _, _, err := w.effectivePlan(cctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
name := naming.FormatTitleYear(plan.Title, plan.Year)
|
||||
if name == "" {
|
||||
return nil // пустое распознанное название — no-op
|
||||
}
|
||||
if err := w.store.SetDisplayName(cctx, id, name); err != nil {
|
||||
return fmt.Errorf("refresh display name: %w", err)
|
||||
}
|
||||
logctx.From(cctx).Info("display name refreshed", "display_name", name)
|
||||
|
||||
// Переименование раздачи — best-effort по её реальному Torrent.Hash (для
|
||||
// гибрид/v2 он может не совпасть с нашим primary infohash). Отсутствие
|
||||
// раздачи (удалена) — штатный no-op: display_name уже обновлён.
|
||||
t, ok, err := w.torrentByInfohash(cctx, d.HashList())
|
||||
if err != nil {
|
||||
logctx.From(cctx).Warn("refresh display name: qbittorrent lookup failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := w.qbt.RenameTorrent(cctx, t.Hash, name); err != nil {
|
||||
logctx.From(cctx).Warn("refresh display name: qbittorrent rename failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Коды причины (error_code) ухода задачи в review при раскладке (linkPlan) —
|
||||
// корреляционный ключ шага, на котором раскладка остановилась. Свод в одном
|
||||
// месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg.
|
||||
|
||||
Reference in New Issue
Block a user