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