Имя: восстановление 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
+8
View File
@@ -47,6 +47,11 @@ type downloadDetailView struct {
// Живая статистика раздачи (заполняется из снимка воркера). // Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView Seeding seedingView
// Nameable — доступно ручное обновление имени: есть распознанное название,
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
Nameable bool
// Действия по состоянию (как на главной). // Действия по состоянию (как на главной).
Terminal bool Terminal bool
Reviewable bool Reviewable bool
@@ -132,6 +137,9 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
view.Season = seasonSummary(rd.Plan) view.Season = seasonSummary(rd.Plan)
} }
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
switch rd.Provider { switch rd.Provider {
case "", "none": case "", "none":
view.NoBase = rd.Provider == "none" view.NoBase = rd.Provider == "none"
+1
View File
@@ -134,6 +134,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider) r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/source", s.handleAddSource) r.Post("/ui/downloads/{id}/source", s.handleAddSource)
r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase) r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/undo", s.handleUndo)
+100 -14
View File
@@ -479,20 +479,21 @@ func TestDownloadTorrentInfoBlock(t *testing.T) {
// --- Ревью --- // --- Ревью ---
type fakeReviewer struct { type fakeReviewer struct {
data *worker.ReviewData data *worker.ReviewData
applyErr error applyErr error
refined map[string]string refined map[string]string
ignored map[string]string ignored map[string]string
chosen map[string]string chosen map[string]string
providerSet map[string]string providerSet map[string]string
sourceAdded map[string]string sourceAdded map[string]string
applied []string applied []string
deferred []string deferred []string
undone []string undone []string
deleted []string deleted []string
relinked []string relinked []string
rerecognized []string rerecognized []string
cleared []string cleared []string
nameRefreshed []string
} }
func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) { 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) f.cleared = append(f.cleared, id)
return nil return nil
} }
func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
f.nameRefreshed = append(f.nameRefreshed, id)
return nil
}
func seriesReviewData() *worker.ReviewData { func seriesReviewData() *worker.ReviewData {
s, e := 2, 1 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) { func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()} rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
+2 -1
View File
@@ -57,7 +57,8 @@ func (stubReviewer) SetProviderID(context.Context, string, string, string) error
func (stubReviewer) AddManualSource(context.Context, string, string, string) error { func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
return nil return nil
} }
func (stubReviewer) ClearProvider(context.Context, string) error { return nil } func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
func (stubReviewer) RefreshDisplayName(context.Context, string) error { return nil }
// stubLive — заглушка источника живой телеметрии. // stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live } type stubLive struct{ m map[string]worker.Live }
+24
View File
@@ -28,6 +28,7 @@ type Reviewer interface {
SetProviderID(ctx context.Context, id string, provider, providerID string) error SetProviderID(ctx context.Context, id string, provider, providerID string) error
AddManualSource(ctx context.Context, id, provider, providerID string) error AddManualSource(ctx context.Context, id, provider, providerID string) error
ClearProvider(ctx context.Context, id string) error ClearProvider(ctx context.Context, id string) error
RefreshDisplayName(ctx context.Context, id string) error
} }
// --- Представление страницы ревью --- // --- Представление страницы ревью ---
@@ -229,6 +230,29 @@ func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleRefreshName переливает распознанное каноническое имя в display_name и в
// ярлык раздачи (ручная кнопка на странице загрузки). Свопит главную область
// (#download-main) с обновлённым заголовком; без htmx — PRG на страницу загрузки
// (в отличие от surfaceAction, уводящего на список: смысл действия — увидеть
// новое имя здесь же). Ошибку на htmx-пути показываем 200 + фрагментом.
func (s *server) handleRefreshName(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := s.deps.Reviewer.RefreshDisplayName(r.Context(), id)
if !isHTMX(r) {
if actionErr != nil {
redirectErr(w, r, userErr(r, actionErr, id))
return
}
http.Redirect(w, r, "/download/"+id, http.StatusSeeOther)
return
}
s.renderDownloadFragment(w, r, id, actionErr)
}
var errInvalidCandidate = errors.New("некорректный id кандидата") var errInvalidCandidate = errors.New("некорректный id кандидата")
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)") var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")
+22
View File
@@ -69,6 +69,12 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает // magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
// "" если имя получить не удалось (тогда вызывающий не задаёт rename). // "" если имя получить не удалось (тогда вызывающий не задаёт rename).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string { func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
return ""
}
if n.provider != nil { if n.provider != nil {
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok { if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
if name := render(ex); name != "" { if name := render(ex); name != "" {
@@ -79,6 +85,22 @@ func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string
return fallbackName(contextText, hint) return fallbackName(contextText, hint)
} }
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
// распознанного названия — без сети и LLM (перелив канонического имени после
// распознавания). Год опционален; имя очищается от управляющих символов и
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
func FormatTitleYear(title string, year int) string {
title = sanitize(title)
if title == "" {
return ""
}
if year > 0 {
title += " (" + strconv.Itoa(year) + ")"
}
return truncate(title, maxNameLen)
}
// render собирает из структуры короткий ярлык: // render собирает из структуры короткий ярлык:
// - movie: "Title (Director, Year)" — режиссёр и год опциональны; // - movie: "Title (Director, Year)" — режиссёр и год опциональны;
// - series: то же + ". Сезон N", если сезон задан. // - series: то же + ". Сезон N", если сезон задан.
+47
View File
@@ -49,6 +49,53 @@ func TestDeriveNameViaLLM(t *testing.T) {
} }
} }
func TestDeriveNameEmptyInputSkipsLLM(t *testing.T) {
// Голый magnet без dn/контекста: выводить имя не из чего — LLM не зовём.
fp := &fakeProvider{responses: []string{
`{"type":"movie","title":"Unknown"}`, // как раз мусор, который был бы записан
}}
got := New(fp, 3, testLogger()).DeriveName(context.Background(), " ", " ")
if got != "" {
t.Errorf("DeriveName() = %q, want empty", got)
}
if fp.calls != 0 {
t.Errorf("вызовов LLM = %d, want 0 (пустой вход не зовёт модель)", fp.calls)
}
}
func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
// Контекст пуст, но есть подсказка (dn) — гард пустого входа НЕ срабатывает:
// имя выводится фолбеком из подсказки. LLM здесь не зовётся (он работает
// только по контексту, а его нет) — это прежнее поведение, гард его не меняет.
fp := &fakeProvider{responses: []string{`{"type":"movie","title":"x"}`}}
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "", "Dune.Part.Two.2024")
if got == "" {
t.Error("при наличии подсказки имя не должно быть пустым (гард не должен глотать hint)")
}
if fp.calls != 0 {
t.Errorf("вызовов LLM = %d, want 0 (LLM выводит по контексту, которого нет)", fp.calls)
}
}
func TestFormatTitleYear(t *testing.T) {
cases := []struct {
title string
year int
want string
}{
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
{"Брат", 0, "Брат"},
{" Fargo\n", 2015, "Fargo (2015)"},
{"", 2024, ""},
{" ", 0, ""},
}
for _, c := range cases {
if got := FormatTitleYear(c.title, c.year); got != c.want {
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want)
}
}
}
func TestDeriveNameRussianTitle(t *testing.T) { func TestDeriveNameRussianTitle(t *testing.T) {
fp := &fakeProvider{responses: []string{ fp := &fakeProvider{responses: []string{
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`, `{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
+41
View File
@@ -306,6 +306,47 @@ func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool)
return nil return nil
} }
// RenameTorrent задаёт отображаемое имя уже добавленной раздачи (в отличие от
// параметра rename при Add, действующего только в момент добавления). hash —
// ключ раздачи в qBittorrent (Torrent.Hash), а не сырой infohash загрузки:
// вызывающий резолвит его листингом. Косметика: имя раздачи не влияет на файлы.
func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
hash = strings.TrimSpace(hash)
if hash == "" {
return fmt.Errorf("qbittorrent rename: empty hash")
}
form := url.Values{"hash": {hash}, "name": {name}}
body := form.Encode()
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/rename", Start: time.Now()}
resp, err := c.do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", c.base.String())
return req, nil
})
if err != nil {
call.Failure(log, err)
return fmt.Errorf("qbittorrent rename: %w", err)
}
defer func() { _ = resp.Body.Close() }()
call.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
err := fmt.Errorf("qbittorrent rename: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(respBody)))
call.Failure(log, err)
return err
}
call.Success(log)
return nil
}
// Torrents возвращает задачи указанной категории (пустая — все). // Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) { func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
log := logctx.FromOr(ctx, c.log) log := logctx.FromOr(ctx, c.log)
+45
View File
@@ -139,6 +139,51 @@ func TestDeleteSendsHashesAndDeleteFiles(t *testing.T) {
} }
} }
func TestRenameTorrentSendsHashAndName(t *testing.T) {
var gotHash, gotName string
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotHash = r.PostForm.Get("hash")
gotName = r.PostForm.Get("name")
_, _ = w.Write([]byte("Ok."))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
if err := c.RenameTorrent(context.Background(), "aaa", "Harold and the Purple Crayon (2024)"); err != nil {
t.Fatalf("RenameTorrent: %v", err)
}
if gotHash != "aaa" {
t.Errorf("hash = %q, want aaa", gotHash)
}
if gotName != "Harold and the Purple Crayon (2024)" {
t.Errorf("name = %q", gotName)
}
}
func TestRenameTorrentEmptyHashIsError(t *testing.T) {
c := newClient(t, "http://unused")
if err := c.RenameTorrent(context.Background(), " ", "x"); err == nil {
t.Fatal("RenameTorrent with empty hash must error before any request")
}
}
func TestRenameTorrentNon200IsError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/torrents/rename", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Found", http.StatusNotFound)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := newClient(t, srv.URL)
if err := c.RenameTorrent(context.Background(), "aaa", "x"); err == nil {
t.Fatal("RenameTorrent must error on non-200")
}
}
func TestDeleteNoHashesIsError(t *testing.T) { func TestDeleteNoHashesIsError(t *testing.T) {
c := newClient(t, "http://unused") c := newClient(t, "http://unused")
if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil { if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil {
+15
View File
@@ -719,6 +719,21 @@ WHERE id = ? AND state = ?`,
return nil 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 — вызов из гарда // setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён; // (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу. // иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
+28
View File
@@ -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 — нетерминальное активное состояние: его наличие блокирует повторный // catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»). // приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) { func TestCatchedIsActiveForDedup(t *testing.T) {
+69
View File
@@ -15,6 +15,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/metadata" "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/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "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", logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID) "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 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 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) — // Коды причины (error_code) ухода задачи в review при раскладке (linkPlan) —
// корреляционный ключ шага, на котором раскладка остановилась. Свод в одном // корреляционный ключ шага, на котором раскладка остановилась. Свод в одном
// месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg. // месте (как errCode* в worker.go); человекочитаемый текст кладётся в error_msg.
+143
View File
@@ -463,6 +463,13 @@ func (m *memStore) PromoteCatched(_ context.Context, id, displayName string) err
return nil 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 { func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok { if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n 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) { func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"}) w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil { if err := w.ChooseCandidate(context.Background(), "1", "999"); err == nil {
+5
View File
@@ -49,6 +49,9 @@ type Store interface {
// PromoteCatched атомарно переводит catched → downloading с записью имени // PromoteCatched атомарно переводит catched → downloading с записью имени
// (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки). // (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки).
PromoteCatched(ctx context.Context, id, displayName string) error PromoteCatched(ctx context.Context, id, displayName string) error
// SetDisplayName обновляет отображаемое имя постфактум (перелив канонического
// имени после распознавания) — без гарда состояния, FSM не двигает.
SetDisplayName(ctx context.Context, id, name string) error
SetSourceMissCount(ctx context.Context, id string, n int) error SetSourceMissCount(ctx context.Context, id string, n int) error
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
// SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта // SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта
@@ -94,6 +97,8 @@ type QBittorrent interface {
Add(ctx context.Context, ar qbt.AddRequest) error Add(ctx context.Context, ar qbt.AddRequest) error
Files(ctx context.Context, hash string) ([]qbt.File, error) Files(ctx context.Context, hash string) ([]qbt.File, error)
Delete(ctx context.Context, hashes []string, deleteFiles bool) error Delete(ctx context.Context, hashes []string, deleteFiles bool) error
// RenameTorrent задаёт имя уже добавленной раздачи по её ключу (Torrent.Hash).
RenameTorrent(ctx context.Context, hash, name string) error
} }
// Recognizer — распознаватель (recognize.Recognizer). // Recognizer — распознаватель (recognize.Recognizer).
+34 -6
View File
@@ -191,6 +191,15 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
return nil 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 { func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { 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 } func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct { type fakeQbt struct {
torrents []qbt.Torrent torrents []qbt.Torrent
added []qbt.AddRequest torrentsErr error
addErr error added []qbt.AddRequest
files []qbt.File addErr error
deleted [][]string // хеши каждого вызова Delete files []qbt.File
deleteErr error deleted [][]string // хеши каждого вызова Delete
deleteErr error
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
renameErr error
}
type renameCall struct {
hash string
name string
} }
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе // Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
@@ -271,6 +288,9 @@ type fakeQbt struct {
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна // регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash. // теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) { func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if f.torrentsErr != nil {
return nil, f.torrentsErr
}
if category == "" { if category == "" {
return f.torrents, nil return f.torrents, nil
} }
@@ -303,6 +323,14 @@ func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
return nil 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 { func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
w := New(st, qb, nil, nil, Config{ w := New(st, qb, nil, nil, Config{
Category: "jellybit", Category: "jellybit",
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-10
@@ -0,0 +1,182 @@
## Context
Отображаемое имя (`download.display_name`) выводится **один раз** — на шаге
добавления пойманной загрузки в qBittorrent (`worker.processCatched`
`namer.DeriveName``PromoteCatched`). Источник имени на этом шаге — только
пользовательский контекст и подсказка из полей источника (`dn` magnet / имя
`.torrent`). Дерева файлов ещё нет (magnet не разрезолвлен), поэтому для голого
magnet без контекста выводить имя не из чего.
Наблюдавшийся дефект (`download_id=01kx5sk1q2vdm7xgyznrz5kheg`): при пустом входе
`DeriveName` всё равно вызывает LLM, и модель возвращает мусорное непустое имя
(«Unknown»). Оно пишется в `display_name` и в `rename` qBittorrent и блокирует
UI-фолбэк `downloadTitle` (тот отдаёт непустой `display_name` вместо
распознанного названия). Между тем к моменту ревью каноническое имя **уже
вычислено и сохранено**: `recognition.title`/`year`, а при выборе кандидата —
пины `title`/`year` через `SetOverride` (`chooseCandidateLocked`) и
`metadata_candidate.title`/`year`. Ничто не переливает его обратно.
Ограничения: инвариант «источник неприкосновенен», отображаемое имя —
исключительно косметика (не влияет на пути/распознавание/раскладку); переименовать
можно только свою раздачу (адресация по infohash-владению).
## Goals / Non-Goals
**Goals:**
- Убрать корень «Unknown»: не выводить имя из пустого входа (не звать LLM, когда
нет ни контекста, ни подсказки).
- Дать способ обновить `display_name` и имя раздачи в qBittorrent на уже
вычисленное каноническое имя — авто (при подтверждённом матче) и вручную
(кнопка), **без нового вызова LLM**.
- Добавить операцию переименования существующей раздачи в qBittorrent.
**Non-Goals:**
- Переизобретать вывод имени: новый LLM-вызов по контексту не делаем (контекст
тот же скудный, что дал «Unknown»).
- Локализация/выбор языка названия — берём каноническое как есть (то же, что
использует раскладка).
- Суффикс сезона в имени сериала — распознавание не несёт скалярного `season`
(сезоны — per-file); ограничиваемся `Title (Year)`.
- Изменения схемы БД — используем существующий `download.display_name`.
## Decisions
### D1. Источник имени — эффективное распознанное название, не новый LLM
Обновлённое имя берём из **эффективного** распознанного названия: пины
`title`/`year` (если матч подтверждён выбором кандидата/ручным пином) поверх
`recognition.title`/`year`. Это ровно то, что использует раскладка, — значит имя
согласовано с тем, куда лягут файлы.
- _Альтернатива A: заново звать `namer.DeriveName` по контексту._ Отклонено:
контекст тот же скудный, что уже дал «Unknown»; распознавание видело дерево
файлов и точнее.
- _Альтернатива B: `rec_title` как есть._ `recognition.title` без года —
беднее; формат `Title (Year)` информативнее и совпадает с ярлыком add-шага.
### D2. Детерминированный формат имени
Формат — `Title (Year)` (год опционален; при отсутствии — просто `Title`),
очистка от управляющих символов и обрезка по длине переиспользуют существующую
логику `naming` (`sanitize`/`truncate`). Без сети, без LLM.
Форматтер `Title (Year)` живёт в пакете `naming` (там же, где `sanitize`/
`truncate` — их придётся экспортировать) и вызывается и авто-триггером, и ручным
эндпоинтом. Он **не** унифицируется с add-шаговым `render()`: тот даёт иной формат
`Title (Director, Year)` + `. Сезон N`; общими остаются только `sanitize`/
`truncate`, но не сам форматтер (у refresh нет режиссёра/скалярного сезона).
### D3. Переименование раздачи в qBittorrent — новая best-effort операция
Добавляем `qbt.Client.RenameTorrent(ctx, hash, name)``POST
/api/v2/torrents/rename` (form `hash`, `name`).
Адресация: qBittorrent индексирует раздачу собственным `hash`, который для
гибридных/v2-only раздач может не совпадать с нашим `PrimaryInfohash()` (первый из
сохранённых v1/v2). Поэтому helper резолвит раздачу через существующий
`torrentByInfohash` (`review.go`) по любому из наших хешей и передаёт в
`RenameTorrent` настоящий `t.Hash`. Побочно это бесплатно закрывает случай
«раздача удалена» — резолв не находит `t`, rename не вызывается (best-effort no-op).
Переименование в qBittorrent — **best-effort**: сбой (раздача уже удалена, qBit
недоступен) НЕ проваливает обновление имени. `display_name` в нашей БД
обновляется в любом случае; ошибку qBit логируем `WARN` (внешний вызов —
`ext.service=qbittorrent`). Обоснование: имя косметическое, недоступность qBit не
должна блокировать команду ревью или кнопку.
### D4. Точки входа: авто при подтверждённом матче + ручная кнопка
Помощник расщеплён по контракту блокировки (конвенция суффикса `*Locked` в
worker): вся логика — в `refreshDisplayNameLocked(ctx, id)`, **вызывается под
`w.mu`**; публичная обёртка `RefreshDisplayName(ctx, id)` берёт `w.mu` сама.
`refreshDisplayNameLocked`:
1. читает загрузку и текущее распознавание с учётом пинов;
2. формирует имя (D2); пустое имя → ничего не делаем (no-op);
3. пишет `display_name` (новый store-метод, D5);
4. резолвит раздачу через `torrentByInfohash` и best-effort переименовывает её в
qBittorrent (D3).
- **Авто:** `chooseCandidateLocked` (уже под `w.mu`) вызывает
`refreshDisplayNameLocked` **после** успешного `SetCandidateChosen`.
`chooseCandidateLocked` — общий путь и для выбора кандидата из списка
(`ChooseCandidate`), и для ручного добавления источника
(`AddManualSource`); оба — подтверждение матча, оба получают авто-refresh
(согласуется с инвариантом «авто-действие только при подтверждённом матче»).
Наблюдавшийся кейс (`matched=false` → ручной выбор кандидата) закрывается
именно этим.
- **Ручной:** HTTP-эндпоинт `POST /ui/downloads/{id}/refresh-name` вызывает
обёртку `RefreshDisplayName` (берёт `w.mu`) и возвращает htmx-партиал
заголовка. Доступен, когда у загрузки есть распознавание (в т.ч. `done`/
`orphaned`, а не только reviewable-состояния — см. D8).
Замечание по блокировке: авто-путь делает сетевой резолв `Torrents`/`RenameTorrent`
под `w.mu` (как уже делают `Apply`/`Delete`), т.е. на время rename команды ревью
заблокированы. Приемлемо: rename — один короткий вызов; альтернатива (вынести из-под
mu) усложнила бы ре-валидацию state и не стоит того для косметики.
### D5. Store-метод обновления имени пост-фактум
Текущий `PromoteCatched` пишет `display_name` только на переходе
`catched→downloading`. Нужен отдельный метод `SetDisplayName(ctx, id, name)`
(UPDATE `display_name`, `updated_at`), не завязанный на состояние: обновление
имени валидно в `review`/`done` и не двигает FSM.
### D6. Политика перезаписи
- **Ручная кнопка:** всегда перезаписывает (явное действие пользователя).
- **Авто при подтверждённом матче:** перезаписывает, когда выведенное имя
непусто. Подтверждённый матч авторитетнее add-догадки; отдельного
пользовательского редактирования `display_name` в системе нет, затирать нечего.
### D7. Гард пустого входа в naming (корень «Unknown»)
`DeriveName`: если `strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint)
== ""` — сразу вернуть `""`, не вызывая ни LLM, ни фолбек. Приём добавляет
загрузку без `rename`, `display_name` пуст, UI берёт заголовок из фолбека
(распознанное имя или усечённый источник). Позже refresh донесёт каноническое имя.
### D8. Видимость кнопки «Обновить имя» — по наличию распознавания
Кнопка гейтится **наличием распознавания** (`rd.Recognition != nil`/наличие плана),
а НЕ полем `Reviewable`. Иначе на `done`/`orphaned` (распознавание уже есть, но
состояние не reviewable) кнопка пропала бы — а именно там она и нужна, чтобы
переименовать раздачу постфактум. Так же это оставляет запас на будущее: при
включённых метабазах авто-раскладка (`finishRecognition`, Auto без review) минует
`chooseCandidateLocked`, и единственной точкой обновления имени для голого magnet
на `done` останется кнопка.
## Risks / Trade-offs
- **Переименование раздачи в qBittorrent удивит пользователя, следящего за
клиентом.** → Это и есть цель (осмысленное имя вместо «Unknown»); срабатывает
только при подтверждённом матче или явной кнопке.
- **Каноническое имя на языке оригинала может отличаться от ожидаемого.** →
Non-goal (D1/Non-Goals); берём то же имя, что и раскладка, — консистентность
важнее локализации.
- **Гонки: раздачу удалили между чтением и rename.** → Best-effort (D3): rename
не проваливает операцию, `display_name` уже обновлён.
- **Затирание add-имени слегка иным форматом.** → Приемлемо (D6): подтверждённый
матч авторитетнее; формат совпадает с add-ярлыком.
## Migration Plan
Обратная совместимость полная: схема БД не меняется, старые загрузки продолжают
работать. Развёртывание — обычный деплой бинаря. Откат — откат бинаря; данные не
мигрируют. Уже существующие «Unknown»-загрузки чинятся ручной кнопкой или
повторным подтверждением кандидата.
## Open Questions
Разрешено на ревью дизайна (чекпоинт №1):
- **Авто-триггер держим узким:** только `chooseCandidateLocked` (выбор кандидата +
ручное добавление источника) + ручная кнопка. `SetProviderID` и `Apply`
авто-refresh НЕ получают. Причины: `SetProviderID` пинит пустые `title`/`year`
(`sourcePins(...,"",0)`) — эффективное имя всё равно падает на
`recognition.title`, «подтверждённости» меньше, а кнопка это закрывает; `Apply`
относится к `file-layout`, вшивать туда косметику — размывать границы capability
и дёргать rename на каждом повторном apply.
@@ -0,0 +1,67 @@
## Why
При бедном первичном приёме (голый magnet без `dn` и без контекста) вывод имени
на шаге добавления зовёт LLM на пустом входе, и модель галлюцинирует мусорное имя
(наблюдалось «Unknown»). Это имя пишется и в `download.display_name`, и в `rename`
qBittorrent, и вдобавок ломает UI-фолбэк на распознанное название (`downloadTitle`
отдаёт непустой `display_name`). Позже распознавание по дереву файлов даёт верное
имя (в наблюдавшемся случае — «Harold and the Purple Crayon», 2024), но это имя
никуда не переливается: и карточка загрузки, и раздача в qBittorrent остаются с
«Unknown».
## What Changes
- **Гард пустого входа при выводе имени.** Если и контекст, и подсказка (`dn`
magnet / имя `.torrent`) пусты, система не зовёт LLM и считает имя не
выведенным (`display_name` остаётся пустым, `rename` не задаётся). Убирает
корень «Unknown» — приём не присваивает имя из пустоты.
- **Обновление имени по подтверждённому распознаванию.** Появляется поведение
«перелить» уже вычисленное каноническое имя (из распознавания / выбранного
кандидата метабазы) в `download.display_name` и в имя раздачи qBittorrent.
Детерминированный формат `Title (Year)` (для сериала — по аналогии с текущим
выводом имени), **без нового вызова LLM** — источник уже сохранён
(`recognition.title/year`, `metadata_candidate.title/year`).
- **Авто:** при подтверждении матча в ревью (выбор кандидата) — согласуется с
инвариантом «авто-действие только при подтверждённом матче».
- **Ручной:** кнопка «Обновить имя» на карточке загрузки.
- **Переименование существующей раздачи в qBittorrent.** Новая операция клиента
qBittorrent `rename` для уже добавленной раздачи (сейчас `rename` действует
только при `add`). Адресация — по infohash своей раздачи.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `ingest`: требование «Вывод имени через LLM» ужесточается гардом пустого входа
(нет ни контекста, ни подсказки → LLM не вызывается, имя считается не
выведенным). Добавляется требование «Обновление отображаемого имени по
подтверждённому распознаванию» (источник имени, детерминированный формат,
запись `display_name`, переименование раздачи qBittorrent; точки входа — авто
и ручная; политика перезаписи).
- `review`: подтверждение матча (команда выбора/применения кандидата) SHALL
запускать обновление отображаемого имени по подтверждённому распознаванию.
- `web-ui`: на карточке/странице загрузки появляется htmx-действие «Обновить
имя», обновляющее заголовок на месте.
## Impact
- **Код:**
- `internal/naming/naming.go` — гард пустого входа в `DeriveName`.
- `internal/qbt` — новая операция `RenameTorrent(ctx, hash, name)` (`POST
/api/v2/torrents/rename`).
- `internal/store` — метод обновления `download.display_name` пост-фактум.
- `internal/worker` — перелив имени при подтверждении кандидата в ревью.
- `internal/httpapi` — эндпоинт ручного обновления имени + htmx-партиал
заголовка; кнопка в шаблоне карточки/страницы загрузки.
- **Инварианты безопасности данных:** изменение чисто косметическое
(`display_name` + ярлык раздачи в qBittorrent). Пути на диске
(`paths.movies`/`series`), распознавание и раскладка НЕ затрагиваются; rename
адресуется по infohash своей раздачи.
- **БД:** структура не меняется (используется существующий столбец
`download.display_name`); миграция не требуется.
- **Внешние вызовы:** дополнительный вызов qBittorrent `rename` при обновлении
имени (логируется как `ext.service=qbittorrent`). Новых LLM-вызовов нет.
@@ -0,0 +1,100 @@
## MODIFIED Requirements
### Requirement: Вывод имени через LLM со структурированным выводом
Система SHALL строить отображаемое имя с помощью LLM (структурированный
JSON-вывод), извлекая из контекста тип (movie/series), название, год,
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
Если и контекст, и подсказка из полей источника (`dn` magnet / имя `.torrent`)
пусты, система SHALL считать имя не выведенным и SHALL NOT вызывать LLM (выводить
имя не из чего — вызов на пустом входе способен лишь галлюцинировать). В этом
случае загрузка добавляется без `rename`, а `download.display_name` остаётся
пустым.
Название SHALL быть на русском языке для российского контента и на
английском (оригинальном) — для остального.
Система SHALL предпринять ограниченное число попыток получить от LLM валидный
результат (корректный JSON с непустым названием); бюджет попыток —
`[llm].max_retries` (по умолчанию 3). Транспортные ретраи провайдера LLM
(сетевые сбои, 429, 5xx) в этот счёт не входят.
Недоступность или ошибка LLM SHALL NOT прерывать приём загрузки: система
переходит к алгоритмическому фолбеку.
#### Scenario: LLM возвращает структурированное имя
- **WHEN** LLM по контексту возвращает валидный JSON с непустым названием
- **THEN** система формирует отображаемое имя из его полей (название, год,
для сериала — сезон)
#### Scenario: Российский контент — название на русском
- **WHEN** контент распознан как российский
- **THEN** в отображаемом имени используется русское название
#### Scenario: Исчерпан бюджет попыток LLM
- **WHEN** LLM за отведённые попытки (`[llm].max_retries`) не вернул валидный
результат либо недоступен
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
#### Scenario: Пустой вход — LLM не вызывается
- **GIVEN** пойманная загрузка без пользовательского контекста и без подсказки
из полей источника (голый magnet без `dn`)
- **WHEN** система выводит отображаемое имя на шаге добавления
- **THEN** LLM не вызывается, имя считается не выведенным
- **AND** загрузка добавляется без `rename`, а `download.display_name` пуст
(заголовок в веб-UI берётся из фолбека)
## ADDED Requirements
### Requirement: Обновление отображаемого имени по распознаванию
Система SHALL уметь обновлять отображаемое имя загрузки после того, как
распознавание дало каноническое название, — переливая уже вычисленное имя (без
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
Источником имени SHALL быть **эффективное** распознанное название: пины
`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то
же название, что использует раскладка. Формат SHALL быть коротким детерминированным
ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих
символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой
источник (нет распознавания или пустое название) SHALL приводить к отсутствию
изменений (no-op).
Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей
раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен)
SHALL NOT проваливать обновление — `download.display_name` обновляется в любом
случае, ошибка внешнего вызова логируется. Как и на шаге добавления,
отображаемое имя SHALL влиять только на отображение и SHALL NOT влиять на пути
файлов, распознавание или раскладку.
Обновление имени SHALL иметь две точки входа: **авто** — при подтверждённом
матче (см. capability `review`); **ручную** — по явному действию пользователя.
Ручное действие SHALL перезаписывать текущее имя всегда; авто SHALL перезаписывать,
когда выведенное имя непусто.
#### Scenario: Перелив имени в загрузку и раздачу
- **GIVEN** загрузка с распознанным непустым каноническим названием
- **WHEN** запускается обновление отображаемого имени
- **THEN** `download.display_name` устанавливается в `Title (Year)`
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен
или раздача удалена)
- **THEN** `download.display_name` всё равно обновлён
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
#### Scenario: Нет распознавания — обновление ничего не делает
- **GIVEN** загрузка без распознанного названия (пустой источник имени)
- **WHEN** запускается обновление отображаемого имени
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Подтверждение матча обновляет отображаемое имя
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать каноническое название (`Title (Year)`) в `download.display_name` и в
имя раздачи qBittorrent, без нового вызова LLM. Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата обновляет имя
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
(например, «Unknown») и списком кандидатов
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** выбор кандидата закрепляется как и прежде
- **AND** `download.display_name` обновляется каноническим именем кандидата
`Title (Year)`
- **AND** раздача в qBittorrent переименовывается в то же имя
#### Scenario: Ручное добавление источника обновляет имя
- **GIVEN** загрузка в ревью без совпадений в списке
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
- **THEN** источник закрепляется как и прежде
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
каноническим названием подтверждённого источника
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
- **GIVEN** выбор кандидата в ревью
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
- **AND** команда ревью не возвращает ошибку
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Действие «Обновить имя» на странице загрузки
Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие
«Обновить имя», запускающее обновление отображаемого имени по распознаванию (см.
capability `ingest`). Видимость действия SHALL определяться наличием распознавания
(а не состоянием ревью): в частности, действие SHALL быть доступно и на терминальных
состояниях с распознаванием (`done`/`orphaned`), где раздачу нужно переименовать
постфактум. Действие SHALL обновлять заголовок на месте по htmx-паттерну
(фрагмент заголовка перерисовывается ответом), без перезагрузки страницы, и SHALL
деградировать без JS (обычная форма-POST с переходом на страницу загрузки).
Действие SHALL быть идемпотентным по эффекту: повторный запуск на неизменном
распознавании даёт то же имя. Отсутствие распознавания (пустой источник имени)
SHALL приводить к отсутствию видимых изменений.
#### Scenario: Ручное обновление имени перерисовывает заголовок
- **GIVEN** страница загрузки с распознанным каноническим названием и заголовком
«Unknown»
- **WHEN** пользователь запускает действие «Обновить имя»
- **THEN** заголовок на странице перерисовывается каноническим именем `Title (Year)`
на месте (htmx), без полной перезагрузки
#### Scenario: Деградация без JS
- **GIVEN** клиент без htmx/JS
- **WHEN** пользователь отправляет действие «Обновить имя» обычной формой
- **THEN** сервер выполняет обновление и отвечает страницей загрузки с новым
заголовком
@@ -0,0 +1,40 @@
## 1. Гард пустого входа в naming (корень «Unknown»)
- [x] 1.1 В `internal/naming/naming.go` `DeriveName`: если `strings.TrimSpace(contextText)` и `strings.TrimSpace(hint)` оба пусты — вернуть `""` до вызова LLM и фолбека
- [x] 1.2 Тест: пустой контекст + пустой hint → `""`, провайдер LLM не вызван (фейковый provider фиксирует отсутствие вызова)
- [x] 1.3 Тест регрессии: непустой hint (или контекст) при пустом другом — прежнее поведение сохранено
## 2. Операция переименования раздачи в qBittorrent
- [x] 2.1 В `internal/qbt` добавить `Client.RenameTorrent(ctx, hash, name)``POST /api/v2/torrents/rename` (form `hash`, `name`), логирование внешнего вызова как `ext.service=qbittorrent`, `ext.operation=torrents/rename`
- [x] 2.2 Тест клиента qBittorrent на форму запроса и разбор ответа/ошибки (по образцу существующих тестов `Add`/`Delete`)
## 3. Store: обновление display_name пост-фактум
- [x] 3.1 В `internal/store` добавить `SetDisplayName(ctx, id, name)` — UPDATE `display_name`, `updated_at`, без гарда состояния (не двигает FSM)
- [x] 3.2 Тест store: имя обновляется в `review`/`done`; `updated_at` продвинут
## 4. Форматтер и помощник обновления имени
- [x] 4.1 В пакете `naming`: экспортировать `sanitize`/`truncate` (или добавить публичную обёртку) и добавить детерминированный форматтер `FormatTitleYear(title string, year int) string` (`Title (Year)`, год опционален; очистка/обрезка) — без LLM. НЕ унифицировать с `render()` (у refresh нет режиссёра/сезона)
- [x] 4.2 Добавить в интерфейсы worker методы `RenameTorrent` (QBittorrent) и `SetDisplayName` (Store)
- [x] 4.3 Реализовать `refreshDisplayNameLocked(ctx, id)` (вызывается ПОД `w.mu`): прочитать загрузку и эффективное распознанное название (пины `title`/`year` поверх `recognition.title`/`year`), собрать имя через `naming.FormatTitleYear`, при пустом — no-op; записать `display_name` через `SetDisplayName`; затем резолвить раздачу через `torrentByInfohash` и best-effort `RenameTorrent` по её реальному `t.Hash` (не по сырому primary infohash) — сбой/отсутствие раздачи логировать `WARN`, операцию не проваливать
- [x] 4.4 Публичная обёртка `RefreshDisplayName(ctx, id)`: берёт `w.mu` и вызывает `refreshDisplayNameLocked` (для внешнего вызова из httpapi)
- [x] 4.5 Тесты worker: перелив имени в `display_name` + вызов `RenameTorrent` с реальным `t.Hash`; недоступность/отсутствие раздачи qBittorrent → `display_name` обновлён, ошибки нет; отсутствие распознавания → no-op
## 5. Авто-триггер при подтверждённом матче (review)
- [x] 5.1 В `chooseCandidateLocked` после успешного `SetCandidateChosen` вызвать `refreshDisplayNameLocked(ctx, id)` (общий путь покрывает и `ChooseCandidate`, и `AddManualSource`)
- [x] 5.2 Тесты: выбор кандидата (`ChooseCandidate`) и ручное добавление источника (`AddManualSource`) обновляют `display_name` каноническим именем и переименовывают раздачу; ошибка qBittorrent не ломает команду
## 6. Ручное действие «Обновить имя» (web-ui)
- [x] 6.1 Эндпоинт `POST /ui/downloads/{id}/refresh-name` в `internal/httpapi`: вызвать `RefreshDisplayName` (обёртка с `w.mu`), вернуть htmx-партиал заголовка (ветвление по `isHTMX`), деградация без JS — редирект/страница загрузки с новым заголовком
- [x] 6.2 Кнопка «Обновить имя» в шаблоне страницы загрузки, видимая при наличии распознавания (`rd.Recognition != nil`/наличие плана), включая `done`/`orphaned` — НЕ гейтить по `Reviewable`; htmx-обновление фрагмента заголовка на месте
- [x] 6.3 Тест httpapi: POST на загрузку с распознаванием возвращает партиал с новым заголовком; идемпотентность (повтор даёт то же имя); загрузка без распознавания → без изменений; кнопка видна на `done`
## 7. Финализация
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate refresh-display-name --strict` проходит
- [x] 7.3 Ревью кода (чекпоинт перед archive): сверка со спеками + инварианты/конвенции
+63
View File
@@ -61,6 +61,12 @@ SHALL обрезаться по ограничению длины.
JSON-вывод), извлекая из контекста тип (movie/series), название, год, JSON-вывод), извлекая из контекста тип (movie/series), название, год,
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля. режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
Если и контекст, и подсказка из полей источника (`dn` magnet / имя `.torrent`)
пусты, система SHALL считать имя не выведенным и SHALL NOT вызывать LLM (выводить
имя не из чего — вызов на пустом входе способен лишь галлюцинировать). В этом
случае загрузка добавляется без `rename`, а `download.display_name` остаётся
пустым.
Название SHALL быть на русском языке для российского контента и на Название SHALL быть на русском языке для российского контента и на
английском (оригинальном) — для остального. английском (оригинальном) — для остального.
@@ -89,6 +95,15 @@ JSON-вывод), извлекая из контекста тип (movie/series)
результат либо недоступен результат либо недоступен
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку - **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
#### Scenario: Пустой вход — LLM не вызывается
- **GIVEN** пойманная загрузка без пользовательского контекста и без подсказки
из полей источника (голый magnet без `dn`)
- **WHEN** система выводит отображаемое имя на шаге добавления
- **THEN** LLM не вызывается, имя считается не выведенным
- **AND** загрузка добавляется без `rename`, а `download.display_name` пуст
(заголовок в веб-UI берётся из фолбека)
### Requirement: Алгоритмический фолбек вывода имени без сети ### Requirement: Алгоритмический фолбек вывода имени без сети
При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых
@@ -111,6 +126,54 @@ JSON-вывод), извлекая из контекста тип (movie/series)
- **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени - **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени
- **THEN** система добавляет загрузку без параметра `rename` - **THEN** система добавляет загрузку без параметра `rename`
### Requirement: Обновление отображаемого имени по распознаванию
Система SHALL уметь обновлять отображаемое имя загрузки после того, как
распознавание дало каноническое название, — переливая уже вычисленное имя (без
нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent.
Источником имени SHALL быть **эффективное** распознанное название: пины
`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то
же название, что использует раскладка. Формат SHALL быть коротким детерминированным
ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих
символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой
источник (нет распознавания или пустое название) SHALL приводить к отсутствию
изменений (no-op).
Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей
раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен)
SHALL NOT проваливать обновление — `download.display_name` обновляется в любом
случае, ошибка внешнего вызова логируется. Как и на шаге добавления,
отображаемое имя SHALL влиять только на отображение и SHALL NOT влиять на пути
файлов, распознавание или раскладку.
Обновление имени SHALL иметь две точки входа: **авто** — при подтверждённом
матче (см. capability `review`); **ручную** — по явному действию пользователя.
Ручное действие SHALL перезаписывать текущее имя всегда; авто SHALL перезаписывать,
когда выведенное имя непусто.
#### Scenario: Перелив имени в загрузку и раздачу
- **GIVEN** загрузка с распознанным непустым каноническим названием
- **WHEN** запускается обновление отображаемого имени
- **THEN** `download.display_name` устанавливается в `Title (Year)`
- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей
раздачи)
#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено
- **GIVEN** обновление отображаемого имени с выведенным непустым именем
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен
или раздача удалена)
- **THEN** `download.display_name` всё равно обновлён
- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается
#### Scenario: Нет распознавания — обновление ничего не делает
- **GIVEN** загрузка без распознанного названия (пустой источник имени)
- **WHEN** запускается обновление отображаемого имени
- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op)
### Requirement: Приём источника и заведение загрузки ### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
+39
View File
@@ -181,6 +181,45 @@ qBittorrent, но и **готов к раскладке** — раздача в
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный - **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник источник
### Requirement: Подтверждение матча обновляет отображаемое имя
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать каноническое название (`Title (Year)`) в `download.display_name` и в
имя раздачи qBittorrent, без нового вызова LLM. Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата обновляет имя
- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name`
(например, «Unknown») и списком кандидатов
- **WHEN** пользователь выбирает кандидата, подтверждая матч
- **THEN** выбор кандидата закрепляется как и прежде
- **AND** `download.display_name` обновляется каноническим именем кандидата
`Title (Year)`
- **AND** раздача в qBittorrent переименовывается в то же имя
#### Scenario: Ручное добавление источника обновляет имя
- **GIVEN** загрузка в ревью без совпадений в списке
- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч
- **THEN** источник закрепляется как и прежде
- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются
каноническим названием подтверждённого источника
#### Scenario: Недоступность qBittorrent не ломает выбор кандидата
- **GIVEN** выбор кандидата в ревью
- **WHEN** переименование раздачи в qBittorrent завершается ошибкой
- **THEN** выбор кандидата и обновление `download.display_name` выполнены
- **AND** команда ревью не возвращает ошибку
### Requirement: Инфо и предпросмотр выбранного источника ### Requirement: Инфо и предпросмотр выбранного источника
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
+30
View File
@@ -174,6 +174,36 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной - **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
строки сырой источник, а не многострочный magnet строки сырой источник, а не многострочный magnet
### Requirement: Действие «Обновить имя» на странице загрузки
Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие
«Обновить имя», запускающее обновление отображаемого имени по распознаванию (см.
capability `ingest`). Видимость действия SHALL определяться наличием распознавания
(а не состоянием ревью): в частности, действие SHALL быть доступно и на терминальных
состояниях с распознаванием (`done`/`orphaned`), где раздачу нужно переименовать
постфактум. Действие SHALL обновлять заголовок на месте по htmx-паттерну
(фрагмент заголовка перерисовывается ответом), без перезагрузки страницы, и SHALL
деградировать без JS (обычная форма-POST с переходом на страницу загрузки).
Действие SHALL быть идемпотентным по эффекту: повторный запуск на неизменном
распознавании даёт то же имя. Отсутствие распознавания (пустой источник имени)
SHALL приводить к отсутствию видимых изменений.
#### Scenario: Ручное обновление имени перерисовывает заголовок
- **GIVEN** страница загрузки с распознанным каноническим названием и заголовком
«Unknown»
- **WHEN** пользователь запускает действие «Обновить имя»
- **THEN** заголовок на странице перерисовывается каноническим именем `Title (Year)`
на месте (htmx), без полной перезагрузки
#### Scenario: Деградация без JS
- **GIVEN** клиент без htmx/JS
- **WHEN** пользователь отправляет действие «Обновить имя» обычной формой
- **THEN** сервер выполняет обновление и отвечает страницей загрузки с новым
заголовком
### Requirement: Матч с записью метабазы ссылкой ### Requirement: Матч с записью метабазы ссылкой
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb) Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
@@ -19,6 +19,7 @@
</div> </div>
<div class="card-acts" style="align-items:center"> <div class="card-acts" style="align-items:center">
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}} {{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
{{if .Nameable}}<form method="post" action="/ui/downloads/{{.ID}}/refresh-name" hx-post="/ui/downloads/{{.ID}}/refresh-name" hx-target="#download-main" hx-swap="outerHTML"><button class="btn btn-ghost" type="submit" title="Перелить распознанное название в имя загрузки и раздачи">Обновить имя</button></form>{{end}}
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}} {{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo" hx-post="/ui/downloads/{{.ID}}/undo" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Привязать заново</button></form>{{end}} {{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink" hx-post="/ui/downloads/{{.ID}}/relink" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Повторить</button></form>{{end}} {{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry" hx-post="/ui/downloads/{{.ID}}/retry" hx-target="#download-main" hx-swap="outerHTML"><input type="hidden" name="surface" value="download"><button class="btn" type="submit">Повторить</button></form>{{end}}