Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)

Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 08:12:28 +03:00
co-authored by Claude Opus 4.8
parent 2ed9c9020f
commit 1f4267a046
17 changed files with 942 additions and 157 deletions
+67
View File
@@ -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] → «13», [1,3,4] → «1, 34».
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 {
+53
View File
@@ -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))}, "Сезоны 13"},
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (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)
}
}
-1
View File
@@ -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)
+78 -14
View File
@@ -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), "<!doctype html>") {
t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
}
if !strings.Contains(string(body), `id="source-block"`) {
t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
}
}
+63 -19
View File
@@ -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 — тогда