From 0c9421f4c168cc6ae5565d21ed20f2d8e724eead Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 10 Jul 2026 16:51:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BC=D1=8F:=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?display=5Fname=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20+=20=D0=B3=D0=B0=D1=80=D0=B4=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Голый 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) --- internal/httpapi/download.go | 8 + internal/httpapi/httpapi.go | 1 + internal/httpapi/httpapi_test.go | 114 +++++++++-- internal/httpapi/render_test.go | 3 +- internal/httpapi/review.go | 24 +++ internal/naming/naming.go | 22 +++ internal/naming/naming_test.go | 47 +++++ internal/qbt/qbt.go | 41 ++++ internal/qbt/qbt_test.go | 45 +++++ internal/store/download.go | 15 ++ internal/store/download_test.go | 28 +++ internal/worker/review.go | 69 +++++++ internal/worker/review_test.go | 143 ++++++++++++++ internal/worker/worker.go | 5 + internal/worker/worker_test.go | 40 +++- .../.openspec.yaml | 2 + .../2026-07-10-refresh-display-name/design.md | 182 ++++++++++++++++++ .../proposal.md | 67 +++++++ .../specs/ingest/spec.md | 100 ++++++++++ .../specs/review/spec.md | 40 ++++ .../specs/web-ui/spec.md | 31 +++ .../2026-07-10-refresh-display-name/tasks.md | 40 ++++ openspec/specs/ingest/spec.md | 63 ++++++ openspec/specs/review/spec.md | 39 ++++ openspec/specs/web-ui/spec.md | 30 +++ web/templates/partials/download_main.html | 1 + 26 files changed, 1179 insertions(+), 21 deletions(-) create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/design.md create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/proposal.md create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-07-10-refresh-display-name/tasks.md diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 1d2aab8..6d74026 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -47,6 +47,11 @@ type downloadDetailView struct { // Живая статистика раздачи (заполняется из снимка воркера). Seeding seedingView + // Nameable — доступно ручное обновление имени: есть распознанное название, + // которое можно перелить в display_name/ярлык раздачи. Гейтится наличием + // распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью. + Nameable bool + // Действия по состоянию (как на главной). Terminal bool Reviewable bool @@ -132,6 +137,9 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet view.Season = seasonSummary(rd.Plan) } view.Year = rd.Plan.Year + // Ручное обновление имени доступно, когда есть распознанное название, + // которое можно перелить (иначе FormatTitleYear даст пусто → no-op). + view.Nameable = rd.Plan.Title != "" switch rd.Provider { case "", "none": view.NoBase = rd.Provider == "none" diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 9b1353e..161e071 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -134,6 +134,7 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/provider", s.handleSetProvider) 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}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/undo", s.handleUndo) diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 9acd3cf..31468f8 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -479,20 +479,21 @@ func TestDownloadTorrentInfoBlock(t *testing.T) { // --- Ревью --- type fakeReviewer struct { - data *worker.ReviewData - applyErr error - refined map[string]string - ignored map[string]string - chosen map[string]string - providerSet map[string]string - sourceAdded map[string]string - applied []string - deferred []string - undone []string - deleted []string - relinked []string - rerecognized []string - cleared []string + data *worker.ReviewData + applyErr error + refined map[string]string + ignored map[string]string + chosen map[string]string + providerSet map[string]string + sourceAdded map[string]string + applied []string + deferred []string + undone []string + deleted []string + relinked []string + rerecognized []string + cleared []string + nameRefreshed []string } 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) return nil } +func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error { + f.nameRefreshed = append(f.nameRefreshed, id) + return nil +} func seriesReviewData() *worker.ReviewData { 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) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index e142f77..67a4240 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -57,7 +57,8 @@ func (stubReviewer) SetProviderID(context.Context, string, string, string) error func (stubReviewer) AddManualSource(context.Context, string, string, string) error { 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 — заглушка источника живой телеметрии. type stubLive struct{ m map[string]worker.Live } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 466afe3..f542304 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -28,6 +28,7 @@ type Reviewer interface { SetProviderID(ctx context.Context, id string, provider, providerID string) error AddManualSource(ctx context.Context, id, provider, providerID 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 errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)") diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 5dacc3f..24e7091 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -69,6 +69,12 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer { // magnet (dn), используется только фолбеком, если контекст пуст. Возвращает // "" если имя получить не удалось (тогда вызывающий не задаёт rename). 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 ex, ok := n.extractViaLLM(ctx, contextText, hint); ok { if name := render(ex); name != "" { @@ -79,6 +85,22 @@ func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string 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 собирает из структуры короткий ярлык: // - movie: "Title (Director, Year)" — режиссёр и год опциональны; // - series: то же + ". Сезон N", если сезон задан. diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 17261e4..8641fae 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -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) { fp := &fakeProvider{responses: []string{ `{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`, diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index ade0c4f..fa8556f 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -306,6 +306,47 @@ func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) 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 возвращает задачи указанной категории (пустая — все). func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) { log := logctx.FromOr(ctx, c.log) diff --git a/internal/qbt/qbt_test.go b/internal/qbt/qbt_test.go index 4bdb8ad..aaa684e 100644 --- a/internal/qbt/qbt_test.go +++ b/internal/qbt/qbt_test.go @@ -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) { c := newClient(t, "http://unused") if err := c.Delete(context.Background(), []string{"", " "}, true); err == nil { diff --git a/internal/store/download.go b/internal/store/download.go index b593128..69da064 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -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 не даёт молча оживить терминальную задачу. diff --git a/internal/store/download_test.go b/internal/store/download_test.go index b1d8688..7a74346 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -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) { diff --git a/internal/worker/review.go b/internal/worker/review.go index af64cfd..c669e6d 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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. diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 4b8529c..9eaa329 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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 { diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 82d208e..8c299c1 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -49,6 +49,9 @@ type Store interface { // PromoteCatched атомарно переводит catched → downloading с записью имени // (гард state='catched' — ре-валидация после сетевых вызовов вне блокировки). 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 SetSourceAddedAt(ctx context.Context, id string, t time.Time) error // SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта @@ -94,6 +97,8 @@ type QBittorrent interface { Add(ctx context.Context, ar qbt.AddRequest) error Files(ctx context.Context, hash string) ([]qbt.File, error) Delete(ctx context.Context, hashes []string, deleteFiles bool) error + // RenameTorrent задаёт имя уже добавленной раздачи по её ключу (Torrent.Hash). + RenameTorrent(ctx context.Context, hash, name string) error } // Recognizer — распознаватель (recognize.Recognizer). diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 8a2b38b..6267978 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -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", diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/.openspec.yaml b/openspec/changes/archive/2026-07-10-refresh-display-name/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/design.md b/openspec/changes/archive/2026-07-10-refresh-display-name/design.md new file mode 100644 index 0000000..1046d29 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/design.md @@ -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. diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/proposal.md b/openspec/changes/archive/2026-07-10-refresh-display-name/proposal.md new file mode 100644 index 0000000..d7628aa --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/proposal.md @@ -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-вызовов нет. diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/specs/ingest/spec.md b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/ingest/spec.md new file mode 100644 index 0000000..7f43288 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/ingest/spec.md @@ -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) diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/specs/review/spec.md b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/review/spec.md new file mode 100644 index 0000000..2a0901e --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/review/spec.md @@ -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** команда ревью не возвращает ошибку diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/web-ui/spec.md new file mode 100644 index 0000000..99788fa --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/specs/web-ui/spec.md @@ -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** сервер выполняет обновление и отвечает страницей загрузки с новым + заголовком diff --git a/openspec/changes/archive/2026-07-10-refresh-display-name/tasks.md b/openspec/changes/archive/2026-07-10-refresh-display-name/tasks.md new file mode 100644 index 0000000..8c221f7 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-refresh-display-name/tasks.md @@ -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): сверка со спеками + инварианты/конвенции diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index 95d1631..1a9795b 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -61,6 +61,12 @@ SHALL обрезаться по ограничению длины. JSON-вывод), извлекая из контекста тип (movie/series), название, год, режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля. +Если и контекст, и подсказка из полей источника (`dn` magnet / имя `.torrent`) +пусты, система SHALL считать имя не выведенным и SHALL NOT вызывать LLM (выводить +имя не из чего — вызов на пустом входе способен лишь галлюцинировать). В этом +случае загрузка добавляется без `rename`, а `download.display_name` остаётся +пустым. + Название SHALL быть на русском языке для российского контента и на английском (оригинальном) — для остального. @@ -89,6 +95,15 @@ JSON-вывод), извлекая из контекста тип (movie/series) результат либо недоступен - **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку +#### Scenario: Пустой вход — LLM не вызывается + +- **GIVEN** пойманная загрузка без пользовательского контекста и без подсказки + из полей источника (голый magnet без `dn`) +- **WHEN** система выводит отображаемое имя на шаге добавления +- **THEN** LLM не вызывается, имя считается не выведенным +- **AND** загрузка добавляется без `rename`, а `download.display_name` пуст + (заголовок в веб-UI берётся из фолбека) + ### Requirement: Алгоритмический фолбек вывода имени без сети При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых @@ -111,6 +126,54 @@ JSON-вывод), извлекая из контекста тип (movie/series) - **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени - **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: Приём источника и заведение загрузки Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP, diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index 1b5a62f..d82f475 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -181,6 +181,45 @@ qBittorrent, но и **готов к раскладке** — раздача в - **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: Инфо и предпросмотр выбранного источника В едином блоке выбора источника экран ревью SHALL показывать для **выбранного diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 94da4ca..9dcb714 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -174,6 +174,36 @@ jellybit (`created_at`). Порядок MUST быть согласован ме - **AND** если нет ни того, ни другого — заголовком служит усечённый до одной строки сырой источник, а не многострочный 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: Матч с записью метабазы ссылкой Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb) diff --git a/web/templates/partials/download_main.html b/web/templates/partials/download_main.html index 1ee41f8..f0a8726 100644 --- a/web/templates/partials/download_main.html +++ b/web/templates/partials/download_main.html @@ -19,6 +19,7 @@
{{if .Reviewable}}Ревью →{{end}} + {{if .Nameable}}
{{end}} {{if .Undoable}}
{{end}} {{if .Relinkable}}
{{end}} {{if .Retriable}}
{{end}}