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