diff --git a/docs/backlog.md b/docs/backlog.md index 264591d..3839580 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -236,6 +236,26 @@ Web-сторона реализована: страница загрузки `/d Связано: [review-ux.md](specs/review-ux.md), [recognition.md](specs/recognition.md) (матч в базе), [architecture.md](specs/architecture.md) → «Транспорты». +### Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки _(идея)_ + +Обычный случай сериальной раздачи — один сезон (его номер надо сразу видеть +глазами и сверять на ревью — под это сделана сводка сезонов в инфо-части, см. +`openspec/specs/review`). Но в редких заказах раздача бывает сложнее: **все +сезоны сериала разом**, **пак нескольких сезонов**, смешанная нумерация, вложенные +папки сезонов, разнобойные имена файлов. Сейчас `PlanFile.Season` задаётся на +каждом файле (мультисезон в принципе выразим), но целостно эти сценарии не +проработаны: как надёжно распознать многосезонную раздачу, как показать её на +ревью (сводка — лишь страховка, не полноценный разбор по сезонам), как разложить +и как это стыкуется со сходимостью папки и merge-докачиванием. Проработать +крайние случаи и решить, что поддерживаем явно, а что уводим в ревью как «сложную +раскладку». + +Связано: [recognition.md](specs/recognition.md) (сезон-паки, нумерация), +[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка сезонов), +[review-ux.md](specs/review-ux.md) (крайние сценарии, сводка сезонов), +[«Проблема второго сезона»](#проблема-второго-сезона), +[«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении). + ### Аниме с абсолютной нумерацией Релизы аниме часто нумеруют серии сквозным числом (`#137`) без сезонов, а @@ -368,6 +388,19 @@ URL кандидата хардкодит `.../dereferrer/series/{id}` (там `openspec/specs/metadata-match` (требование «Кандидат несёт URL»), пакеты `metadata`, `httpapi`. +### Панель действий ревью вне htmx-свопа блока источника + +При выборе источника одним кликом обновляется только блок источника +(`#source-block`) htmx-свопом, а нижняя панель действий (кнопка «Применить», +завязанная на `HasLinks`) — вне блока и не обновляется до полной перезагрузки. +Практически не мешает (хардлинки только по явному «Применить», +`Apply` без плана вернёт ошибку), но в краевом случае (источник с пустым +предпросмотром из-за коллизии) кнопка «Применить» может остаться/пропасть не +синхронно. Решение намечено в дизайне `review-unified-source-block` +(Risks/Trade-offs): обновлять панель `hx-swap-oob` из того же партиала. + +Связано: `openspec/specs/review`, `openspec/specs/web-ui`, пакет `httpapi`. + ### Мгновенные обновления через SSE Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто diff --git a/internal/httpapi/files.go b/internal/httpapi/files.go index 5bf2503..6dc41a0 100644 --- a/internal/httpapi/files.go +++ b/internal/httpapi/files.go @@ -1,6 +1,10 @@ package httpapi import ( + "sort" + "strconv" + "strings" + "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/recognize" ) @@ -36,6 +40,69 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow { return rows } +// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по +// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому +// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры: +// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски». +func seasonSummary(plan recognize.Plan) string { + seen := map[int]bool{} + specials := false + for _, f := range plan.Files { + if f.Role != recognize.RoleEpisode { + continue + } + n := 0 + if f.Season != nil { + n = *f.Season + } + if n <= 0 { + specials = true + continue + } + seen[n] = true + } + nums := make([]int, 0, len(seen)) + for n := range seen { + nums = append(nums, n) + } + sort.Ints(nums) + + var parts []string + switch { + case len(nums) == 1: + parts = append(parts, "Сезон "+strconv.Itoa(nums[0])) + case len(nums) > 1: + parts = append(parts, "Сезоны "+seasonRanges(nums)) + } + if specials { + if len(parts) == 0 { + parts = append(parts, "Спецвыпуски") + } else { + parts = append(parts, "спецвыпуски") + } + } + return strings.Join(parts, ", ") +} + +// seasonRanges схлопывает возрастающие номера сезонов в диапазоны: +// [1,2,3] → «1–3», [1,3,4] → «1, 3–4». +func seasonRanges(nums []int) string { + var out []string + for i := 0; i < len(nums); { + j := i + for j+1 < len(nums) && nums[j+1] == nums[j]+1 { + j++ + } + if j == i { + out = append(out, strconv.Itoa(nums[i])) + } else { + out = append(out, strconv.Itoa(nums[i])+"–"+strconv.Itoa(nums[j])) + } + i = j + 1 + } + return strings.Join(out, ", ") +} + // roleLabel — человекочитаемая роль файла раскладки. func roleLabel(role string) string { switch role { diff --git a/internal/httpapi/files_internal_test.go b/internal/httpapi/files_internal_test.go new file mode 100644 index 0000000..55e6b13 --- /dev/null +++ b/internal/httpapi/files_internal_test.go @@ -0,0 +1,53 @@ +package httpapi + +import ( + "testing" + + "git.vakhrushev.me/av/jellybit/internal/recognize" +) + +func TestSeasonSummary(t *testing.T) { + // ep — эпизодный файл с заданным (или nil) сезоном. + ep := func(season *int) recognize.PlanFile { + return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season} + } + n := func(v int) *int { return &v } + + cases := []struct { + name string + files []recognize.PlanFile + want string + }{ + {"пусто", nil, ""}, + {"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"}, + {"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 1–3"}, + {"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"}, + {"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"}, + {"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"}, + {"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"}, + {"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"}, + {"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := seasonSummary(recognize.Plan{Files: c.files}) + if got != c.want { + t.Errorf("seasonSummary = %q, want %q", got, c.want) + } + }) + } +} + +// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов. +func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) { + s := 2 + plan := recognize.Plan{Files: []recognize.PlanFile{ + {Role: recognize.RoleEpisode, Season: &s}, + {Role: recognize.RoleMain}, + {Role: recognize.RoleSubtitle}, + {Role: recognize.RoleIgnore}, + }} + if got := seasonSummary(plan); got != "Сезон 2" { + t.Errorf("seasonSummary = %q, want «Сезон 2»", got) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index e87d1da..61bc688 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -114,7 +114,6 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize) - r.Post("/ui/downloads/{id}/type", s.handleSetType) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/provider", s.handleSetProvider) diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 574d246..1b5f443 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -395,7 +396,6 @@ type fakeReviewer struct { data *worker.ReviewData applyErr error refined map[string]string - typed map[string]string ignored map[string]string chosen map[string]string providerSet map[string]string @@ -425,13 +425,6 @@ func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error { f.refined[id] = hint return nil } -func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error { - if f.typed == nil { - f.typed = map[string]string{} - } - f.typed[id] = t - return nil -} func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error { if f.ignored == nil { f.ignored = map[string]string{} @@ -541,11 +534,16 @@ func TestReviewRenders(t *testing.T) { } for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv", "Season 02", "Применить", "Уточнить", - "Источник совпадения", "269613", "выбрать", "распознано нейронкой", "Добавить"} { + "Источник и раскладка", "269613", "распознано нейронкой", "Добавить", + "Сезон 2"} { if !strings.Contains(string(body), want) { t.Errorf("страница ревью не содержит %q", want) } } + // Кнопки «выбрать» больше нет — выбор одним кликом по радио. + if strings.Contains(string(body), ">выбрать<") { + t.Error("страница ревью всё ещё содержит кнопку «выбрать»") + } } func TestReviewShowsMatchLink(t *testing.T) { @@ -653,6 +651,45 @@ func TestAddManualSource_RejectsBadURL(t *testing.T) { } } +// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает +// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не +// добавлен. +func TestAddManualSource_HTMXError(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}} + req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source", + strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + resp, err := noRedirectClient().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode) + } + if _, called := rv.sourceAdded[tid]; called { + t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded) + } + if !strings.Contains(string(body), `id="source-block"`) { + t.Error("htmx-ответ не содержит блок источника") + } + if !strings.Contains(string(body), "block-error") { + t.Error("htmx-ответ не содержит баннер ошибки блока") + } + if strings.Contains(string(body), "внутренняя ошибка") { + t.Error("ошибка ввода подана как внутренняя") + } +} + func TestApplyRedirectsToIndex(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, @@ -708,7 +745,7 @@ func TestRefinePostsHint(t *testing.T) { } } -func TestIgnoreAndType(t *testing.T) { +func TestIgnoreFile(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) @@ -721,13 +758,40 @@ func TestIgnoreAndType(t *testing.T) { if rv.ignored[tid] != "Fargo/sample.mkv" { t.Errorf("IgnoreFile получил %q", rv.ignored[tid]) } +} - if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/type", - map[string][]string{"type": {"movie"}}); err != nil { +// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока +// источника (а не полную страницу и не редирект), обновлённый под выбор. +func TestChooseCandidateHTMX(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate", + strings.NewReader("candidate_id="+cid)) + if err != nil { t.Fatal(err) } - if rv.typed[tid] != "movie" { - t.Errorf("SetType получил %q", rv.typed[tid]) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + resp, err := noRedirectClient().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode) + } + if rv.chosen[tid] != cid { + t.Errorf("ChooseCandidate получил %q", rv.chosen[tid]) + } + // Ответ — партиал блока, а не полная страница. + if strings.Contains(string(body), "") { + t.Error("htmx-ответ должен быть партиалом, а не полной страницей") + } + if !strings.Contains(string(body), `id="source-block"`) { + t.Error("htmx-ответ не содержит корневой контейнер блока #source-block") } } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index eddb32b..164965e 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -18,7 +18,6 @@ type Reviewer interface { ReviewData(ctx context.Context, id string) (*worker.ReviewData, error) Apply(ctx context.Context, id string) error Refine(ctx context.Context, id string, hint string) error - SetType(ctx context.Context, id string, mediaType string) error IgnoreFile(ctx context.Context, id string, src string) error Defer(ctx context.Context, id string) error Undo(ctx context.Context, id string) error @@ -44,6 +43,7 @@ type reviewView struct { Title string OriginalTitle string Year int + SeasonSummary string // сводка сезонов для сериала (пусто для фильма) Provider string ProviderID string MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом) @@ -55,10 +55,13 @@ type reviewView struct { HasLinks bool // есть хотя бы один целевой путь → можно применять NoBase bool // выбрано «без базы» Sources []sourceView // единый список источников совпадения + BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=) } // sourceView — строка единого списка источников на экране ревью: нейронка или -// кандидат базы, с эффективными полями и предпросмотром целевых путей. +// кандидат базы. Инфо и предпросмотр раскладки показываются для активного +// источника из верхнеуровневых полей reviewView, поэтому per-source превью +// строка не несёт. type sourceView struct { Kind string // "neural" | "candidate" CandidateID string @@ -66,10 +69,8 @@ type sourceView struct { ProviderID string Title string Year int - IsSeries bool MatchURL string Active bool - Files []fileRow // предпросмотр «файл → раскладка» этого источника } func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { @@ -90,12 +91,20 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { return } + s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err"))) +} + +// buildReviewView собирает представление страницы ревью из доменных данных. +// Общий для полной страницы (handleReview) и htmx-свопа блока источника +// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути, +// там ошибка идёт в BlockError). +func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView { view := reviewView{ ID: id, Source: shorten(rd.Download.SourceRef, 80), Context: rd.Download.Context, State: string(rd.Download.State), - Error: r.URL.Query().Get("err"), + Error: errMsg, StateError: rd.Download.ErrorMsg.String, Hints: rd.Hints, } @@ -105,6 +114,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { view.Title = rd.Plan.Title view.OriginalTitle = rd.Plan.OriginalTitle view.Year = rd.Plan.Year + if view.IsSeries { + view.SeasonSummary = seasonSummary(rd.Plan) + } view.Reasons = rec.ReasonList() switch rd.Provider { case "", "none": @@ -128,9 +140,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { ProviderID: src.ProviderID, Title: src.Title, Year: src.Year, - IsSeries: src.Type == "series", Active: src.Active, - Files: buildFileRows(src.Plan, src.Preview), } if src.Kind == worker.SourceCandidate { sv.MatchURL = sourceMatchURL(src) @@ -138,8 +148,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { view.Sources = append(view.Sources, sv) } } - - s.render(w, "review.html", view) + return view } // --- Действия ревью (POST → redirect) --- @@ -172,13 +181,6 @@ func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) { }) } -func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) { - s.reviewAction(w, r, func(ctx context.Context, id string) error { - _ = r.ParseForm() - return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type")) - }) -} - func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) { s.reviewAction(w, r, func(ctx context.Context, id string) error { _ = r.ParseForm() @@ -187,7 +189,7 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) { } func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) { - s.reviewAction(w, r, func(ctx context.Context, id string) error { + s.reviewBlockAction(w, r, func(ctx context.Context, id string) error { _ = r.ParseForm() // Входная граница: id кандидата из формы валидируется как ULID. candidateID, err := ident.Parse(r.PostForm.Get("candidate_id")) @@ -206,7 +208,7 @@ func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) { } func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) { - s.reviewAction(w, r, func(ctx context.Context, id string) error { + s.reviewBlockAction(w, r, func(ctx context.Context, id string) error { return s.deps.Reviewer.ClearProvider(ctx, id) }) } @@ -214,7 +216,7 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) { // handleAddSource добавляет источник вручную по id или URL записи метабазы и // выбирает его. Разбор ввода — на входной границе транспорта. func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) { - s.reviewAction(w, r, func(ctx context.Context, id string) error { + s.reviewBlockAction(w, r, func(ctx context.Context, id string) error { _ = r.ParseForm() provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id")) if err != nil { @@ -364,6 +366,48 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co redirectReview(w, r, id, "") } +// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу). +func isHTMX(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} + +// reviewBlockAction — помощник для действий выбора источника: выполнить +// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает +// состояние и рендерит партиал `review_source_block` (ошибку кладёт в +// BlockError, активный источник не меняется); без htmx деградирует до +// PRG-редиректа, как reviewAction. +func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + actionErr := fn(r.Context(), id) + + if !isHTMX(r) { + msg := "" + if actionErr != nil { + msg = userErr(r, actionErr, id) + } + redirectReview(w, r, id, msg) + return + } + + // htmx: перечитываем состояние (уже с новым активным источником при успехе) + // и рендерим свежий партиал блока. + rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) + if err != nil { + s.deps.Logger.Error("review data", "id", id, "error", err) + http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) + return + } + view := buildReviewView(id, rd, "") + if actionErr != nil { + view.BlockError = userErr(r, actionErr, id) + } + s.render(w, "review_source_block", view) +} + // matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL // выбранного кандидата, но только если его provider+id совпадают с эффективными // (человек мог выбрать кандидата, затем вручную переопределить id — тогда diff --git a/openspec/changes/archive/2026-07-04-review-unified-source-block/.openspec.yaml b/openspec/changes/archive/2026-07-04-review-unified-source-block/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-review-unified-source-block/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-04-review-unified-source-block/design.md b/openspec/changes/archive/2026-07-04-review-unified-source-block/design.md new file mode 100644 index 0000000..79f3cdd --- /dev/null +++ b/openspec/changes/archive/2026-07-04-review-unified-source-block/design.md @@ -0,0 +1,177 @@ +## Context + +Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас +состоит из трёх секций: + +- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год; +- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого + не-активного варианта — нативный `
` с предпросмотром раскладки, и + отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма + ручного добавления (POST `/source`); +- **Раскладка** — та же таблица предпросмотра, но для активного источника. + +Все действия — обычные `
` c PRG-редиректом (`reviewAction` → +`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью +не используется; на других страницах он уже применяется для фрагментов +(`hx-get .../progress`, `every 3s`). + +Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption` +(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр; +верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют +**активному** источнику. Инвариант «превью == применённое» обеспечивается тем, +что выбор источника пишет те же пины, что показаны в превью. + +## Goals / Non-Goals + +**Goals:** + +- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном → + предпросмотр раскладки выбранного. +- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр + обновляются немедленно, без полной перезагрузки (htmx частичный своп блока). +- Тип показывать read-only; убрать переключатель типа с веб-экрана. +- Сохранить: ручное добавление источника, инвариант «превью == применённое», + тонкость транспорта (доменная логика в `worker` не трогается). + +**Non-Goals:** + +- Менять доменный слой `internal/worker/review.go` (выбор источника, построение + плана/превью остаются как есть). +- Менять команду `SetType` в домене и её доступность в Telegram (убираем только + веб-контрол). +- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных + источников» — отдельная будущая задача, не входит сюда. + +## Decisions + +### Решение 1: htmx частичный своп единого блока (не полная перезагрузка) + +Выделяем единый блок в партиал `web/templates/partials/review_source_block.html` +с корневым контейнером `id="source-block"`. Партиал рендерится из того же +`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей +активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`), +предпросмотр — из `.Files`. + +Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`, +`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт +выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает +радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал +блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже +относятся к новому активному источнику. + +Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности; +кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка — +пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует). + +**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ +должна попадать в кликабельную зону label — иначе клик по ссылке заодно +переключит источник. Выносим ссылку из `